feat: post composition redesign + budget allocation + brand identity (Rawaj)

Post Workflow:
- PostDetail full page (/posts/:id) replaces slide panel approach
- Post = 1 Caption Copy + 1 Body Copy + 1 Design + 0-1 Video
- copy_type field on Translations (caption/body)
- Composition endpoint returns rich data (content preview, languages, thumbnails)
- Stage auto-advances on translation/artefact changes (both link and unlink)
- "Translations" renamed to "Copy" in navigation
- GET /api/posts/:id, /api/translations/:id, /api/artefacts/:id routes added
- PostProduction: "New Post" creates → navigates to full page
- CampaignDetail: click post → navigates to full page
- Inline link picker (no modals) with search + rich item display
- PostComposition sub-components for caption, copy, designs, video, formats, readiness

Budget Allocation:
- Single source of truth: BudgetEntries (Campaign.budget deprecated)
- Budget mutex for race conditions
- Validation at all levels (main → campaign → track, expenses)
- CEO approval workflow: BudgetRequests table, public approval page
- Finance page: request budget UI, budget requests section
- Settings: CEO email field
- All emails branded with "Rawaj —" prefix

Brand Identity:
- Name: Rawaj (رواج) — trending/virality
- Deep teal palette (#0d9488), forest-tinted dark mode
- DM Sans font, custom SVG logo
- Consistent across login, sidebar, emails, public pages

Approval Workflow:
- Single reviewer per artefact (not multi-select)
- Reviewer redirect on public review page
- Server blocks submit-review without reviewer
- Review URLs use APP_URL (not server URL)

UI/UX:
- Scroll clipping fix: Modal, TabbedModal, SlidePanel restructured
  to avoid overflow-y-auto clipping native select dropdowns
- section-card overflow-hidden → overflow-clip
- All page titles via Header.jsx (removed duplicate h1s)
- CampaignDetail redesigned: prominent budget card, compact team

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
fahed
2026-03-15 18:02:29 +03:00
parent e1d1c392eb
commit ce4d6025d7
50 changed files with 2616 additions and 229 deletions
+75
View File
@@ -0,0 +1,75 @@
const nocodb = require('./nocodb');
async function getPostComposition(postId) {
const post = await nocodb.get('Posts', postId);
if (!post) return null;
const translations = await nocodb.list('Translations', {
where: `(post_id,eq,${postId})`, limit: 100,
});
const caption = translations.find(t => t.copy_type === 'caption') || null;
const bodyCopy = translations.find(t => t.copy_type === 'body' || !t.copy_type) || null;
const artefacts = await nocodb.list('Artefacts', {
where: `(post_id,eq,${postId})`, limit: 100,
});
const design = artefacts.find(a => (a.type || 'design') === 'design') || null;
const video = artefacts.find(a => a.type === 'video') || null;
let platforms = [];
try { platforms = JSON.parse(post.platforms || '[]'); } catch { platforms = post.platform ? [post.platform] : []; }
const waitingOn = [];
if (caption && caption.status !== 'approved') waitingOn.push('Caption');
if (bodyCopy && bodyCopy.status !== 'approved') waitingOn.push('Copy');
if (design && design.status !== 'approved') waitingOn.push('Design');
if (video && video.status !== 'approved') waitingOn.push('Video');
const hasPieces = caption || bodyCopy || design || video;
const piecesReady = hasPieces && waitingOn.length === 0;
// Get translation texts for languages preview
const getTexts = async (translationId) => {
try {
const texts = await nocodb.list('TranslationTexts', { where: `(translation_id,eq,${translationId})`, limit: 20 });
return texts.map(tt => ({ language: tt.language_code || tt.language, status: tt.status || 'draft' }));
} catch { return []; }
};
const captionTexts = caption ? await getTexts(caption.Id) : [];
const bodyTexts = bodyCopy ? await getTexts(bodyCopy.Id) : [];
// Get first attachment for design/video thumbnail
const getFirstAttachment = async (artefactId) => {
try {
const versions = await nocodb.list('ArtefactVersions', { where: `(artefact_id,eq,${artefactId})`, sort: '-version_number', limit: 1 });
if (versions.length === 0) return null;
const attachments = await nocodb.list('ArtefactAttachments', { where: `(version_id,eq,${versions[0].Id})`, limit: 1 });
return attachments.length > 0 ? (attachments[0].url || attachments[0].file_url || null) : null;
} catch { return null; }
};
const designThumb = design ? (design.thumbnail_url || await getFirstAttachment(design.Id)) : null;
const videoThumb = video ? (video.thumbnail_url || await getFirstAttachment(video.Id)) : null;
return {
caption: caption ? { id: caption.Id, title: caption.title, status: caption.status, language: caption.source_language, content_preview: (caption.source_content || '').slice(0, 120), languages: captionTexts } : null,
body_copy: bodyCopy ? { id: bodyCopy.Id, title: bodyCopy.title, status: bodyCopy.status, language: bodyCopy.source_language, content_preview: (bodyCopy.source_content || '').slice(0, 120), languages: bodyTexts } : null,
design: design ? { id: design.Id, title: design.title, status: design.status, thumbnail_url: designThumb, current_version: design.current_version } : null,
video: video ? { id: video.Id, title: video.title, status: video.status, thumbnail_url: videoThumb, current_version: video.current_version } : null,
platforms,
pieces_ready: piecesReady,
waiting_on: waitingOn,
stage: post.stage || 'copy',
};
}
function computeStage(composition) {
const { caption, body_copy, design, video, pieces_ready } = composition;
if (pieces_ready) return 'post';
if (design || video) return 'design';
// Check if we have any copy at all
const hasCopy = caption || body_copy;
if (!hasCopy) return 'copy';
return 'copy';
}
module.exports = { getPostComposition, computeStage };
+202 -11
View File
@@ -155,7 +155,7 @@ const FK_COLUMNS = {
TaskAttachments: ['task_id'],
Comments: ['user_id'],
BudgetEntries: ['campaign_id', 'project_id'],
Artefacts: ['project_id', 'campaign_id'],
Artefacts: ['project_id', 'campaign_id', 'post_id'],
PostVersions: ['post_id', 'created_by_user_id'],
PostVersionTexts: ['version_id'],
Issues: ['brand_id', 'assigned_to_id', 'team_id'],
@@ -516,7 +516,11 @@ const TEXT_COLUMNS = {
BudgetEntries: [{ name: 'project_id', uidt: 'Number' }, { name: 'destination', uidt: 'SingleLineText' }, { name: 'type', uidt: 'SingleLineText' }],
Comments: [{ name: 'version_number', uidt: 'Number' }],
Issues: [{ name: 'thumbnail', uidt: 'SingleLineText' }],
Artefacts: [{ name: 'approver_ids', uidt: 'SingleLineText' }],
Translations: [{ name: 'copy_type', uidt: 'SingleLineText' }],
Artefacts: [
{ name: 'approver_ids', uidt: 'SingleLineText' },
{ name: 'thumbnail_url', uidt: 'SingleLineText' },
],
Posts: [
{ name: 'approver_ids', uidt: 'SingleLineText' },
{ name: 'approval_token', uidt: 'SingleLineText' },
@@ -526,6 +530,8 @@ const TEXT_COLUMNS = {
{ name: 'feedback', uidt: 'LongText' },
{ name: 'current_version', uidt: 'Number' },
{ name: 'review_version', uidt: 'Number' },
{ name: 'caption', uidt: 'LongText' },
{ name: 'stage', uidt: 'SingleLineText' },
],
PostAttachments: [{ name: 'version_id', uidt: 'Number' }],
BudgetRequests: [
@@ -1286,14 +1292,32 @@ app.get('/api/posts', requireAuth, async (req, res) => {
}
});
// Get single post
app.get('/api/posts/:id', requireAuth, async (req, res) => {
try {
const post = await nocodb.get('Posts', req.params.id);
if (!post) return res.status(404).json({ error: 'Post not found' });
const enriched = { ...post };
enriched.brand_name = await getRecordName('Brands', post.brand_id);
enriched.assigned_name = await getRecordName('Users', post.assigned_to_id || post.assigned_to);
enriched.campaign_name = await getRecordName('Campaigns', post.campaign_id);
enriched.creator_name = await getRecordName('Users', post.created_by_user_id);
// Parse platforms
try { enriched.platforms = JSON.parse(post.platforms || '[]'); } catch { enriched.platforms = post.platform ? [post.platform] : []; }
res.json(enriched);
} catch (err) {
console.error('GET /posts/:id error:', err);
res.status(500).json({ error: 'Failed to load post' });
}
});
app.post('/api/posts', requireAuth, async (req, res) => {
const { title, description, brand_id, assigned_to, status, platform, platforms, content_type, scheduled_date, notes, campaign_id, approver_ids } = req.body;
if (!title) return res.status(400).json({ error: 'Title is required' });
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] : []);
try {
const created = await nocodb.create('Posts', {
title, description: description || null,
title: title || 'Untitled', description: description || null,
status: status || 'draft',
platform: platformsArr[0] || null,
platforms: JSON.stringify(platformsArr),
@@ -1305,6 +1329,8 @@ app.post('/api/posts', requireAuth, async (req, res) => {
assigned_to_id: assigned_to ? Number(assigned_to) : null,
campaign_id: campaign_id ? Number(campaign_id) : null,
approver_ids: approver_ids || null,
caption: caption || '',
stage: 'copy',
created_by_user_id: req.session.userId,
});
@@ -1354,7 +1380,7 @@ app.patch('/api/posts/:id', requireAuth, requireOwnerOrRole('posts', 'superadmin
if (!existing) return res.status(404).json({ error: 'Post not found' });
const data = {};
for (const f of ['title', 'description', 'status', 'platform', 'content_type', 'scheduled_date', 'published_date', 'notes']) {
for (const f of ['title', 'description', 'status', 'platform', 'content_type', 'scheduled_date', 'published_date', 'notes', 'caption']) {
if (req.body[f] !== undefined) data[f] = req.body[f];
}
if (req.body.platforms !== undefined) {
@@ -1401,6 +1427,18 @@ app.patch('/api/posts/:id', requireAuth, requireOwnerOrRole('posts', 'superadmin
await nocodb.update('Posts', id, data);
// Auto-update stage
try {
const { getPostComposition, computeStage } = require('./post-composition');
const composition = await getPostComposition(req.params.id);
if (composition) {
const newStage = computeStage(composition);
await nocodb.update('Posts', Number(req.params.id), { stage: newStage });
}
} catch (stageErr) {
console.error('Stage auto-update error:', stageErr);
}
const post = await nocodb.get('Posts', id);
const approverIdList = post.approver_ids ? post.approver_ids.split(',').map(s => s.trim()).filter(Boolean) : [];
const approverNames = {};
@@ -1422,6 +1460,18 @@ app.patch('/api/posts/:id', requireAuth, requireOwnerOrRole('posts', 'superadmin
}
});
app.get('/api/posts/:id/composition', requireAuth, async (req, res) => {
try {
const { getPostComposition } = require('./post-composition');
const composition = await getPostComposition(req.params.id);
if (!composition) return res.status(404).json({ error: 'Post not found' });
res.json(composition);
} catch (err) {
console.error('Composition error:', err);
res.status(500).json({ error: 'Failed to load composition' });
}
});
app.delete('/api/posts/:id', requireAuth, requireOwnerOrRole('posts', 'superadmin', 'manager'), async (req, res) => {
try {
await nocodb.delete('Posts', req.params.id);
@@ -1578,7 +1628,8 @@ app.post('/api/posts/:id/submit-review', requireAuth, requireOwnerOrRole('posts'
}
await nocodb.update('Posts', req.params.id, updateData);
const reviewUrl = `${req.protocol}://${req.get('host')}/review-post/${token}`;
const appUrl = process.env.APP_URL || process.env.CORS_ORIGIN || `${req.protocol}://${req.get('host')}`;
const reviewUrl = `${appUrl}/review-post/${token}`;
res.json({ success: true, token, reviewUrl, expiresAt: expiresAt.toISOString() });
notify.notifyReviewSubmitted({ type: 'post', record: { ...existing, ...updateData }, reviewUrl });
} catch (err) {
@@ -3913,6 +3964,28 @@ app.get('/api/artefacts', requireAuth, async (req, res) => {
}
});
// Get single artefact
app.get('/api/artefacts/:id', requireAuth, async (req, res) => {
try {
const artefact = await nocodb.get('Artefacts', req.params.id);
if (!artefact) return res.status(404).json({ error: 'Artefact not found' });
const enriched = { ...artefact };
enriched.brand_name = await getRecordName('Brands', artefact.brand_id);
enriched.creator_name = await getRecordName('Users', artefact.created_by_user_id);
enriched.project_name = await getRecordName('Projects', artefact.project_id);
enriched.campaign_name = await getRecordName('Campaigns', artefact.campaign_id);
const approverIdList = artefact.approver_ids ? artefact.approver_ids.split(',').map(s => s.trim()).filter(Boolean) : [];
enriched.approvers = [];
for (const aid of approverIdList) {
enriched.approvers.push({ id: Number(aid), name: await getRecordName('Users', Number(aid)) });
}
res.json(enriched);
} catch (err) {
console.error('GET /artefacts/:id error:', err);
res.status(500).json({ error: 'Failed to load artefact' });
}
});
app.post('/api/artefacts', requireAuth, async (req, res) => {
const { title, description, type, brand_id, content, project_id, campaign_id, approver_ids } = req.body;
if (!title) return res.status(400).json({ error: 'Title is required' });
@@ -4021,6 +4094,21 @@ app.patch('/api/artefacts/:id', requireAuth, async (req, res) => {
console.log(`[PATCH /artefacts/${req.params.id}] Updating:`, JSON.stringify(data));
await nocodb.update('Artefacts', req.params.id, data);
// Auto-update linked post stage (both old and new post if post_id changed)
const oldPostId = existing.post_id ? Number(existing.post_id) : null;
const updatedArtefact = await nocodb.get('Artefacts', Number(req.params.id));
const newPostId = updatedArtefact?.post_id ? Number(updatedArtefact.post_id) : null;
const postIdsToUpdate = [...new Set([oldPostId, newPostId].filter(Boolean))];
for (const pid of postIdsToUpdate) {
try {
const { getPostComposition, computeStage } = require('./post-composition');
const composition = await getPostComposition(pid);
if (composition) {
await nocodb.update('Posts', pid, { stage: computeStage(composition) });
}
} catch (e) { console.error('Post stage update error:', e); }
}
const artefact = await nocodb.get('Artefacts', req.params.id);
console.log(`[PATCH /artefacts/${req.params.id}] After re-read: approver_ids=${artefact.approver_ids}`);
const approverIdList = artefact.approver_ids ? artefact.approver_ids.split(',').map(s => s.trim()).filter(Boolean) : [];
@@ -4085,6 +4173,11 @@ app.post('/api/artefacts/:id/submit-review', requireAuth, async (req, res) => {
return res.status(403).json({ error: 'You can only submit your own artefacts' });
}
const approverIds = parseApproverIds(existing.approver_ids);
if (approverIds.length === 0) {
return res.status(400).json({ error: 'Select a reviewer before submitting for review' });
}
const token = require('crypto').randomUUID();
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + DEFAULTS.tokenExpiryDays);
@@ -4096,7 +4189,8 @@ app.post('/api/artefacts/:id/submit-review', requireAuth, async (req, res) => {
review_version: existing.current_version || 1,
});
const reviewUrl = `${req.protocol}://${req.get('host')}/review/${token}`;
const appUrl = process.env.APP_URL || process.env.CORS_ORIGIN || `${req.protocol}://${req.get('host')}`;
const reviewUrl = `${appUrl}/review/${token}`;
res.json({ success: true, token, reviewUrl, expiresAt: expiresAt.toISOString() });
notify.notifyReviewSubmitted({ type: 'artefact', record: existing, reviewUrl });
} catch (err) {
@@ -4689,6 +4783,65 @@ app.post('/api/public/review/:token/revision', async (req, res) => {
}
});
// Public: Get team members for redirect (must be BEFORE /:token routes)
app.get('/api/public/review-redirect/:token/team', async (req, res) => {
try {
const artefacts = await nocodb.list('Artefacts', {
where: `(approval_token,eq,${sanitizeWhereValue(req.params.token)})`,
limit: 1,
});
if (artefacts.length === 0) return res.status(404).json({ error: 'Not found' });
const users = await nocodb.list('Users', { limit: 200 });
const artefact = artefacts[0];
const currentApproverId = artefact.approver_ids ? Number(artefact.approver_ids) : null;
const creatorId = artefact.created_by_user_id ? Number(artefact.created_by_user_id) : null;
res.json(users
.filter(u => u.Id !== currentApproverId && u.Id !== creatorId)
.map(u => ({ id: u.Id, name: u.name }))
);
} catch (err) {
res.status(500).json({ error: 'Failed to load team' });
}
});
app.post('/api/public/review-redirect/:token', async (req, res) => {
const { new_approver_id, reason } = req.body;
if (!new_approver_id) return res.status(400).json({ error: 'New approver ID is required' });
try {
const artefacts = await nocodb.list('Artefacts', {
where: `(approval_token,eq,${sanitizeWhereValue(req.params.token)})`,
limit: 1,
});
if (artefacts.length === 0) return res.status(404).json({ error: 'Review link not found' });
const artefact = artefacts[0];
if (artefact.token_expires_at && new Date(artefact.token_expires_at) < new Date()) {
return res.status(410).json({ error: 'Review link has expired' });
}
if (artefact.status !== 'pending_review') {
return res.status(400).json({ error: 'This artefact is no longer pending review' });
}
// Update approver
await nocodb.update('Artefacts', artefact.Id, {
approver_ids: String(new_approver_id),
});
// Notify the new approver
const appUrl = process.env.APP_URL || process.env.CORS_ORIGIN || `${req.protocol}://${req.get('host')}`;
const reviewUrl = `${appUrl}/review/${artefact.approval_token}`;
notify.notifyReviewSubmitted({ type: 'artefact', record: { ...artefact, approver_ids: String(new_approver_id) }, reviewUrl });
const newApproverName = await getRecordName('Users', Number(new_approver_id));
res.json({ success: true, message: `Review redirected to ${newApproverName}` });
} catch (err) {
console.error('Redirect review error:', err);
res.status(500).json({ error: 'Failed to redirect review' });
}
});
app.post('/api/public/review/:token/comment', async (req, res) => {
const { comment, author_name } = req.body;
if (!comment) return res.status(400).json({ error: 'Comment is required' });
@@ -4785,9 +4938,30 @@ app.get('/api/translations', requireAuth, async (req, res) => {
}
});
// Get single translation
app.get('/api/translations/:id', requireAuth, async (req, res) => {
try {
const translation = await nocodb.get('Translations', req.params.id);
if (!translation) return res.status(404).json({ error: 'Translation not found' });
const enriched = { ...translation };
enriched.brand_name = await getRecordName('Brands', translation.brand_id);
enriched.creator_name = await getRecordName('Users', translation.created_by_user_id);
// Parse approvers
const approverIdList = translation.approver_ids ? translation.approver_ids.split(',').map(s => s.trim()).filter(Boolean) : [];
enriched.approvers = [];
for (const aid of approverIdList) {
enriched.approvers.push({ id: Number(aid), name: await getRecordName('Users', Number(aid)) });
}
res.json(enriched);
} catch (err) {
console.error('GET /translations/:id error:', err);
res.status(500).json({ error: 'Failed to load translation' });
}
});
// Create translation
app.post('/api/translations', requireAuth, async (req, res) => {
const { title, source_language, source_content, brand_id, post_id, approver_ids } = req.body;
const { title, source_language, source_content, brand_id, post_id, approver_ids, copy_type } = req.body;
if (!title) return res.status(400).json({ error: 'Title is required' });
if (!source_language) return res.status(400).json({ error: 'Source language is required' });
if (!source_content) return res.status(400).json({ error: 'Source content is required' });
@@ -4801,6 +4975,7 @@ app.post('/api/translations', requireAuth, async (req, res) => {
brand_id: brand_id ? Number(brand_id) : null,
post_id: post_id ? Number(post_id) : null,
approver_ids: approver_ids || null,
copy_type: copy_type || null,
created_by_user_id: req.session.userId,
});
@@ -4852,7 +5027,7 @@ app.patch('/api/translations/:id', requireAuth, async (req, res) => {
}
const data = {};
for (const f of ['title', 'source_language', 'source_content', 'status', 'feedback']) {
for (const f of ['title', 'source_language', 'source_content', 'status', 'feedback', 'copy_type']) {
if (req.body[f] !== undefined) data[f] = req.body[f];
}
if (req.body.brand_id !== undefined) data.brand_id = req.body.brand_id ? Number(req.body.brand_id) : null;
@@ -4863,6 +5038,21 @@ app.patch('/api/translations/:id', requireAuth, async (req, res) => {
await nocodb.update('Translations', req.params.id, data);
// Auto-update linked post stage (both old and new post if post_id changed)
const oldTransPostId = existing.post_id ? Number(existing.post_id) : null;
const updated = await nocodb.get('Translations', Number(req.params.id));
const newTransPostId = updated?.post_id ? Number(updated.post_id) : null;
const transPostIds = [...new Set([oldTransPostId, newTransPostId].filter(Boolean))];
for (const pid of transPostIds) {
try {
const { getPostComposition, computeStage } = require('./post-composition');
const composition = await getPostComposition(pid);
if (composition) {
await nocodb.update('Posts', pid, { stage: computeStage(composition) });
}
} catch (e) { console.error('Post stage update error:', e); }
}
const record = await nocodb.get('Translations', req.params.id);
const approverIdList = record.approver_ids ? record.approver_ids.split(',').map(s => s.trim()).filter(Boolean) : [];
const approvers = [];
@@ -5074,7 +5264,8 @@ app.post('/api/translations/:id/submit-review', requireAuth, async (req, res) =>
token_expires_at: expiresAt.toISOString(),
});
const reviewUrl = `${req.protocol}://${req.get('host')}/review-translation/${token}`;
const appUrl = process.env.APP_URL || process.env.CORS_ORIGIN || `${req.protocol}://${req.get('host')}`;
const reviewUrl = `${appUrl}/review-translation/${token}`;
res.json({ success: true, token, reviewUrl, expiresAt: expiresAt.toISOString() });
notify.notifyReviewSubmitted({ type: 'translation', record: existing, reviewUrl });
} catch (err) {