feat: public review flow for posts (like artefacts)
All checks were successful
Deploy / deploy (push) Successful in 12s

- Token-based public review page at /review-post/:token
- Submit for Review button generates shareable link
- External reviewers can approve/reject with comments
- Approval gate prevents skipping review (superadmin override)
- i18n keys for review flow in en + ar

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
fahed
2026-03-05 15:16:13 +03:00
parent 8e243517e2
commit 0e948cbf37
6 changed files with 625 additions and 31 deletions

View File

@@ -462,7 +462,14 @@ const TEXT_COLUMNS = {
Comments: [{ name: 'version_number', uidt: 'Number' }],
Issues: [{ name: 'thumbnail', uidt: 'SingleLineText' }],
Artefacts: [{ name: 'approver_ids', uidt: 'SingleLineText' }],
Posts: [{ name: 'approver_ids', uidt: 'SingleLineText' }],
Posts: [
{ name: 'approver_ids', uidt: 'SingleLineText' },
{ name: 'approval_token', uidt: 'SingleLineText' },
{ name: 'token_expires_at', uidt: 'SingleLineText' },
{ name: 'approved_by_name', uidt: 'SingleLineText' },
{ name: 'approved_at', uidt: 'SingleLineText' },
{ name: 'feedback', uidt: 'LongText' },
],
};
async function ensureTextColumns() {
@@ -1279,6 +1286,21 @@ app.patch('/api/posts/:id', requireAuth, requireOwnerOrRole('posts', 'superadmin
if (req.body.campaign_id !== undefined) data.campaign_id = req.body.campaign_id ? Number(req.body.campaign_id) : null;
if (req.body.approver_ids !== undefined) data.approver_ids = req.body.approver_ids || null;
// Approval gate: can't skip to approved/scheduled/published without review
if (['approved', 'scheduled', 'published'].includes(req.body.status) && existing.status !== 'approved' && req.body.status !== existing.status) {
if (req.body.status === 'approved' && existing.status !== 'in_review') {
// Only public review can set approved — unless superadmin
if (req.session.userRole !== 'superadmin') {
return res.status(400).json({ error: 'Post must be approved through the review process' });
}
}
if (['scheduled', 'published'].includes(req.body.status) && existing.status !== 'approved' && existing.status !== 'scheduled') {
if (req.session.userRole !== 'superadmin') {
return res.status(400).json({ error: 'Post must be approved before it can be scheduled or published' });
}
}
}
// Publish validation
if (req.body.status === 'published') {
let currentPlatforms, currentLinks;
@@ -1444,6 +1466,151 @@ app.delete('/api/attachments/:id', requireAuth, async (req, res) => {
}
});
// ─── POST REVIEW / APPROVAL ─────────────────────────────────────
// Submit post for review — generates approval token
app.post('/api/posts/:id/submit-review', requireAuth, requireOwnerOrRole('posts', 'superadmin', 'manager'), async (req, res) => {
try {
const existing = await nocodb.get('Posts', req.params.id);
if (!existing) return res.status(404).json({ error: 'Post not found' });
const token = require('crypto').randomUUID();
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + DEFAULTS.tokenExpiryDays);
await nocodb.update('Posts', req.params.id, {
status: 'in_review',
approval_token: token,
token_expires_at: expiresAt.toISOString(),
approved_by_name: null,
approved_at: null,
feedback: null,
});
const reviewUrl = `${req.protocol}://${req.get('host')}/review-post/${token}`;
res.json({ success: true, token, reviewUrl, expiresAt: expiresAt.toISOString() });
} catch (err) {
console.error('Submit post review error:', err);
res.status(500).json({ error: 'Failed to submit for review' });
}
});
// Public: Get post for review (no auth)
app.get('/api/public/review-post/:token', async (req, res) => {
try {
const posts = await nocodb.list('Posts', {
where: `(approval_token,eq,${sanitizeWhereValue(req.params.token)})`,
limit: 1,
});
if (posts.length === 0) return res.status(404).json({ error: 'Review link not found or expired' });
const post = posts[0];
if (post.token_expires_at && new Date(post.token_expires_at) < new Date()) {
return res.status(410).json({ error: 'Review link has expired' });
}
// Get attachments
const attachments = await nocodb.list('PostAttachments', {
where: `(post_id,eq,${post.Id})`,
limit: QUERY_LIMITS.large,
});
// Resolve approver names
const approvers = [];
if (post.approver_ids) {
for (const id of post.approver_ids.split(',').map(s => s.trim()).filter(Boolean)) {
approvers.push({ id: Number(id), name: await getRecordName('Users', Number(id)) });
}
}
// Get comments
let comments = [];
try {
comments = await nocodb.list('Comments', {
where: `(entity_type,eq,post)~and(entity_id,eq,${post.Id})`,
sort: 'CreatedAt',
limit: QUERY_LIMITS.large,
});
} catch {}
res.json({
...post,
platforms: safeJsonParse(post.platforms, []),
publication_links: safeJsonParse(post.publication_links, []),
brand_name: await getRecordName('Brands', post.brand_id),
assigned_name: await getRecordName('Users', post.assigned_to_id),
creator_name: await getRecordName('Users', post.created_by_user_id),
approvers,
attachments: attachments.map(a => ({
...a,
url: a.url || `/api/uploads/${a.filename}`,
})),
comments,
});
} catch (err) {
console.error('Public post review fetch error:', err);
res.status(500).json({ error: 'Failed to load post for review' });
}
});
// Public: Approve post
app.post('/api/public/review-post/:token/approve', async (req, res) => {
const { approved_by_name, feedback } = req.body;
try {
const posts = await nocodb.list('Posts', {
where: `(approval_token,eq,${sanitizeWhereValue(req.params.token)})`,
limit: 1,
});
if (posts.length === 0) return res.status(404).json({ error: 'Review link not found' });
const post = posts[0];
if (post.token_expires_at && new Date(post.token_expires_at) < new Date()) {
return res.status(410).json({ error: 'Review link has expired' });
}
await nocodb.update('Posts', post.Id, {
status: 'approved',
approved_by_name: approved_by_name || 'Anonymous',
approved_at: new Date().toISOString(),
feedback: feedback || null,
});
res.json({ success: true, message: 'Post approved successfully' });
} catch (err) {
console.error('Post approve error:', err);
res.status(500).json({ error: 'Failed to approve post' });
}
});
// Public: Reject post
app.post('/api/public/review-post/:token/reject', async (req, res) => {
const { approved_by_name, feedback } = req.body;
try {
const posts = await nocodb.list('Posts', {
where: `(approval_token,eq,${sanitizeWhereValue(req.params.token)})`,
limit: 1,
});
if (posts.length === 0) return res.status(404).json({ error: 'Review link not found' });
const post = posts[0];
if (post.token_expires_at && new Date(post.token_expires_at) < new Date()) {
return res.status(410).json({ error: 'Review link has expired' });
}
await nocodb.update('Posts', post.Id, {
status: 'rejected',
approved_by_name: approved_by_name || 'Anonymous',
feedback: feedback || '',
});
res.json({ success: true, message: 'Post rejected' });
} catch (err) {
console.error('Post reject error:', err);
res.status(500).json({ error: 'Failed to reject post' });
}
});
// ─── ASSETS ─────────────────────────────────────────────────────
app.get('/api/assets', requireAuth, async (req, res) => {