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:
+202
-11
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user