feat: public review flow for posts (like artefacts)
All checks were successful
Deploy / deploy (push) Successful in 12s
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:
169
server/server.js
169
server/server.js
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user