diff --git a/client/src/App.jsx b/client/src/App.jsx
index 769dea7..9ad83e4 100644
--- a/client/src/App.jsx
+++ b/client/src/App.jsx
@@ -31,6 +31,7 @@ const Login = lazy(() => import('./pages/Login'))
const Artefacts = lazy(() => import('./pages/Artefacts'))
const PostCalendar = lazy(() => import('./pages/PostCalendar'))
const PublicReview = lazy(() => import('./pages/PublicReview'))
+const PublicPostReview = lazy(() => import('./pages/PublicPostReview'))
const Issues = lazy(() => import('./pages/Issues'))
const PublicIssueSubmit = lazy(() => import('./pages/PublicIssueSubmit'))
const PublicIssueTracker = lazy(() => import('./pages/PublicIssueTracker'))
@@ -291,6 +292,7 @@ function AppContent() {
: } />
: } />
} />
+ } />
} />
} />
: }>
diff --git a/client/src/components/PostDetailPanel.jsx b/client/src/components/PostDetailPanel.jsx
index f06ec24..3e4cbcc 100644
--- a/client/src/components/PostDetailPanel.jsx
+++ b/client/src/components/PostDetailPanel.jsx
@@ -1,5 +1,5 @@
import { useState, useEffect, useRef } from 'react'
-import { X, Trash2, Upload, FileText, Link2, ExternalLink, FolderOpen, Image as ImageIcon, Music, Film, Send, CheckCircle2, XCircle } from 'lucide-react'
+import { X, Trash2, Upload, FileText, Link2, ExternalLink, FolderOpen, Image as ImageIcon, Music, Film, Send, CheckCircle2, XCircle, Copy, Check } from 'lucide-react'
import { useLanguage } from '../i18n/LanguageContext'
import { api, PLATFORMS, getBrandColor } from '../utils/api'
import ApproverMultiSelect from './ApproverMultiSelect'
@@ -7,9 +7,11 @@ import CommentsSection from './CommentsSection'
import Modal from './Modal'
import SlidePanel from './SlidePanel'
import CollapsibleSection from './CollapsibleSection'
+import { useToast } from './ToastContainer'
export default function PostDetailPanel({ post, onClose, onSave, onDelete, brands, teamMembers, campaigns }) {
const { t, lang } = useLanguage()
+ const toast = useToast()
const imageInputRef = useRef(null)
const audioInputRef = useRef(null)
const videoInputRef = useRef(null)
@@ -19,6 +21,11 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
const [publishError, setPublishError] = useState('')
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
+ // Review state
+ const [submittingReview, setSubmittingReview] = useState(false)
+ const [reviewUrl, setReviewUrl] = useState('')
+ const [copied, setCopied] = useState(false)
+
// Attachments state
const [attachments, setAttachments] = useState([])
const [uploading, setUploading] = useState(false)
@@ -136,6 +143,31 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
}
}
+ const handleSubmitReview = async () => {
+ if (!postId || submittingReview) return
+ // Save pending changes first
+ if (dirty) await handleSave()
+ setSubmittingReview(true)
+ try {
+ const res = await api.post(`/posts/${postId}/submit-review`)
+ setReviewUrl(res.reviewUrl || '')
+ setForm(f => ({ ...f, status: 'in_review' }))
+ toast.success(t('posts.submittedForReview'))
+ onSave(postId, {}) // reload parent
+ } catch (err) {
+ toast.error(err.message || t('posts.failedSubmitReview'))
+ } finally {
+ setSubmittingReview(false)
+ }
+ }
+
+ const copyReviewLink = () => {
+ navigator.clipboard.writeText(reviewUrl)
+ setCopied(true)
+ toast.success(t('posts.reviewLinkCopied'))
+ setTimeout(() => setCopied(false), 2000)
+ }
+
const confirmDelete = async () => {
setShowDeleteConfirm(false)
await onDelete(postId)
@@ -403,47 +435,73 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
{!isCreateMode && (
-
+ <>
+ {/* Submit for Review */}
{(form.status === 'draft' || form.status === 'rejected') && (
)}
- {form.status === 'in_review' && (
- <>
-
-
- >
+
+ {/* Review Link */}
+ {reviewUrl && (
+
+
{t('posts.reviewLinkTitle')}
+
+
+
+
+
)}
+
+ {/* Waiting for review */}
+ {form.status === 'in_review' && (
+
+
{t('posts.awaitingReview')}
+
{t('posts.awaitingReviewDesc')}
+
+ )}
+
+ {/* Approved info */}
+ {form.status === 'approved' && post.approved_by_name && (
+
+
+
+ {t('posts.approvedBy')} {post.approved_by_name}
+
+ {post.feedback &&
{post.feedback}
}
+
+ )}
+
+ {/* Rejected info */}
+ {form.status === 'rejected' && post.approved_by_name && (
+
+
+
+ {t('posts.rejectedBy')} {post.approved_by_name}
+
+ {post.feedback &&
{post.feedback}
}
+
+ )}
+
+ {/* Schedule button after approval */}
{form.status === 'approved' && (
)}
-
+ >
)}
diff --git a/client/src/i18n/ar.json b/client/src/i18n/ar.json
index 4c4db22..ec34753 100644
--- a/client/src/i18n/ar.json
+++ b/client/src/i18n/ar.json
@@ -889,5 +889,18 @@
"posts.selectApprovers": "اختر المعتمدين...",
"posts.scheduling": "الجدولة والتعيين",
"posts.content": "المحتوى",
- "posts.reject": "رفض"
+ "posts.reject": "رفض",
+ "posts.submittedForReview": "تم إرسال المنشور للمراجعة",
+ "posts.failedSubmitReview": "فشل إرسال المراجعة",
+ "posts.reviewLinkCopied": "تم نسخ رابط المراجعة!",
+ "posts.reviewLinkTitle": "رابط المراجعة",
+ "posts.awaitingReview": "بانتظار المراجعة",
+ "posts.awaitingReviewDesc": "هذا المنشور بانتظار الموافقة الخارجية.",
+ "posts.approvedBy": "تمت الموافقة من",
+ "posts.rejectedBy": "تم الرفض من",
+ "posts.submitting": "جارٍ الإرسال...",
+ "posts.submitForReview": "إرسال للمراجعة",
+ "posts.schedulePost": "جدولة المنشور",
+ "review.postReview": "مراجعة المنشور",
+ "review.createdBy": "أنشئ بواسطة"
}
\ No newline at end of file
diff --git a/client/src/i18n/en.json b/client/src/i18n/en.json
index 58792ef..974d025 100644
--- a/client/src/i18n/en.json
+++ b/client/src/i18n/en.json
@@ -889,5 +889,18 @@
"posts.selectApprovers": "Select approvers...",
"posts.scheduling": "Scheduling & Assignment",
"posts.content": "Content",
- "posts.reject": "Reject"
+ "posts.reject": "Reject",
+ "posts.submittedForReview": "Post submitted for review",
+ "posts.failedSubmitReview": "Failed to submit for review",
+ "posts.reviewLinkCopied": "Review link copied!",
+ "posts.reviewLinkTitle": "Review Link",
+ "posts.awaitingReview": "Awaiting Review",
+ "posts.awaitingReviewDesc": "This post is waiting for external approval.",
+ "posts.approvedBy": "Approved by",
+ "posts.rejectedBy": "Rejected by",
+ "posts.submitting": "Submitting...",
+ "posts.submitForReview": "Submit for Review",
+ "posts.schedulePost": "Schedule Post",
+ "review.postReview": "Post Review",
+ "review.createdBy": "Created by"
}
\ No newline at end of file
diff --git a/client/src/pages/PublicPostReview.jsx b/client/src/pages/PublicPostReview.jsx
new file mode 100644
index 0000000..e4a9cae
--- /dev/null
+++ b/client/src/pages/PublicPostReview.jsx
@@ -0,0 +1,341 @@
+import { useState, useEffect } from 'react'
+import { useParams } from 'react-router-dom'
+import { CheckCircle, XCircle, FileText, Image as ImageIcon, Film, Music, User, Sparkles } from 'lucide-react'
+import { useLanguage } from '../i18n/LanguageContext'
+import { useToast } from '../components/ToastContainer'
+import Modal from '../components/Modal'
+
+export default function PublicPostReview() {
+ const { token } = useParams()
+ const { t } = useLanguage()
+ const toast = useToast()
+ const [post, setPost] = useState(null)
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState('')
+ const [submitting, setSubmitting] = useState(false)
+ const [success, setSuccess] = useState('')
+ const [reviewerName, setReviewerName] = useState('')
+ const [feedback, setFeedback] = useState('')
+ const [pendingAction, setPendingAction] = useState(null)
+
+ useEffect(() => { loadPost() }, [token])
+
+ const loadPost = async () => {
+ try {
+ const res = await fetch(`/api/public/review-post/${token}`)
+ if (!res.ok) {
+ const err = await res.json()
+ setError(err.error || t('review.loadFailed'))
+ setLoading(false)
+ return
+ }
+ const data = await res.json()
+ setPost(data)
+ if (data.approvers?.length === 1 && data.approvers[0].name) {
+ setReviewerName(data.approvers[0].name)
+ }
+ } catch {
+ setError(t('review.loadFailed'))
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const handleAction = (action) => {
+ if (!reviewerName.trim()) {
+ toast.error(t('review.enterName'))
+ return
+ }
+ setPendingAction(action)
+ }
+
+ const executeAction = async (action) => {
+ setSubmitting(true)
+ try {
+ const res = await fetch(`/api/public/review-post/${token}/${action}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ approved_by_name: reviewerName, feedback: feedback || undefined }),
+ })
+ if (!res.ok) {
+ const err = await res.json()
+ setError(err.error || t('review.actionFailed'))
+ setSubmitting(false)
+ return
+ }
+ const data = await res.json()
+ setSuccess(data.message || t('review.actionCompleted'))
+ setTimeout(() => loadPost(), 1500)
+ } catch {
+ setError(t('review.actionFailed'))
+ } finally {
+ setSubmitting(false)
+ }
+ }
+
+ if (loading) {
+ return (
+
+ )
+ }
+
+ if (error) {
+ return (
+
+
+
+
+
+
{t('review.notAvailable')}
+
{error}
+
+
+ )
+ }
+
+ if (success) {
+ return (
+
+
+
+
+
+
{t('review.thankYou')}
+
{success}
+
+
+ )
+ }
+
+ if (!post) return null
+
+ const images = (post.attachments || []).filter(a => (a.mime_type || '').startsWith('image/'))
+ const audio = (post.attachments || []).filter(a => (a.mime_type || '').startsWith('audio/'))
+ const videos = (post.attachments || []).filter(a => (a.mime_type || '').startsWith('video/'))
+ const others = (post.attachments || []).filter(a => {
+ const m = a.mime_type || ''
+ return !m.startsWith('image/') && !m.startsWith('audio/') && !m.startsWith('video/')
+ })
+
+ const platforms = Array.isArray(post.platforms) ? post.platforms : []
+
+ return (
+
+
+ {/* Header */}
+
+
+
+
+
+
+
+
{t('review.postReview')}
+
Samaya Digital Hub
+
+
+
+
+
+ {/* Post Info */}
+
+
{post.title}
+ {post.description && (
+
{post.description}
+ )}
+
+ {post.brand_name && (
+ {post.brand_name}
+ )}
+ {platforms.length > 0 && (
+ {platforms.join(', ')}
+ )}
+ {post.creator_name && {t('review.createdBy')} {post.creator_name}}
+ {post.scheduled_date && • {new Date(post.scheduled_date).toLocaleDateString()}}
+
+
+
+ {/* Images */}
+ {images.length > 0 && (
+
+
+
+
{t('posts.images')}
+
+
+
+ )}
+
+ {/* Videos */}
+ {videos.length > 0 && (
+
+
+
+
{t('posts.videos')}
+
+
+ {videos.map((att, idx) => (
+
+ {att.original_name && (
+
+ {att.original_name}
+
+ )}
+
+
+ ))}
+
+
+ )}
+
+ {/* Audio */}
+ {audio.length > 0 && (
+
+
+
+
{t('posts.audio')}
+
+
+ {audio.map((att, idx) => (
+
+
+
{att.original_name}
+
+
+ ))}
+
+
+ )}
+
+ {/* Other files */}
+ {others.length > 0 && (
+
+
{t('posts.otherFiles')}
+
+
+ )}
+
+ {/* Review Form */}
+ {post.status === 'in_review' && (
+
+
{t('review.yourReview')}
+
+
+
+
+ {post.approvers?.length === 1 ? (
+
+
+ {post.approvers[0].name}
+
+ ) : post.approvers?.length > 1 ? (
+
+ ) : (
+
setReviewerName(e.target.value)}
+ placeholder={t('review.enterYourName')}
+ className="w-full px-4 py-2 text-sm border border-border rounded-lg bg-surface text-text-primary focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary" />
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+ )}
+
+ {/* Already Reviewed */}
+ {post.status !== 'in_review' && (
+
+
+
{t('review.alreadyReviewed')}
+
+ {t('review.statusLabel')}: {post.status.replace('_', ' ')}
+
+ {post.approved_by_name && (
+
+ {t('review.reviewedBy')}: {post.approved_by_name}
+
+ )}
+ {post.feedback && (
+
"{post.feedback}"
+ )}
+
+
+ )}
+
+
+
+
+
{t('review.poweredBy')}
+
+
+
+
setPendingAction(null)}
+ title={pendingAction === 'approve' ? t('review.confirmApprove') : t('review.confirmReject')}
+ isConfirm
+ danger={pendingAction === 'reject'}
+ onConfirm={() => { const a = pendingAction; setPendingAction(null); executeAction(a) }}
+ confirmText={pendingAction === 'approve' ? t('review.approve') : t('review.reject')}
+ >
+ {pendingAction === 'approve' ? t('review.confirmApproveDesc') : t('review.confirmRejectDesc')}
+
+
+ )
+}
diff --git a/server/server.js b/server/server.js
index 8b8e632..701fd31 100644
--- a/server/server.js
+++ b/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) => {