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

@@ -31,6 +31,7 @@ const Login = lazy(() => import('./pages/Login'))
const Artefacts = lazy(() => import('./pages/Artefacts')) const Artefacts = lazy(() => import('./pages/Artefacts'))
const PostCalendar = lazy(() => import('./pages/PostCalendar')) const PostCalendar = lazy(() => import('./pages/PostCalendar'))
const PublicReview = lazy(() => import('./pages/PublicReview')) const PublicReview = lazy(() => import('./pages/PublicReview'))
const PublicPostReview = lazy(() => import('./pages/PublicPostReview'))
const Issues = lazy(() => import('./pages/Issues')) const Issues = lazy(() => import('./pages/Issues'))
const PublicIssueSubmit = lazy(() => import('./pages/PublicIssueSubmit')) const PublicIssueSubmit = lazy(() => import('./pages/PublicIssueSubmit'))
const PublicIssueTracker = lazy(() => import('./pages/PublicIssueTracker')) const PublicIssueTracker = lazy(() => import('./pages/PublicIssueTracker'))
@@ -291,6 +292,7 @@ function AppContent() {
<Route path="/forgot-password" element={user ? <Navigate to="/" replace /> : <ForgotPassword />} /> <Route path="/forgot-password" element={user ? <Navigate to="/" replace /> : <ForgotPassword />} />
<Route path="/reset-password" element={user ? <Navigate to="/" replace /> : <ResetPassword />} /> <Route path="/reset-password" element={user ? <Navigate to="/" replace /> : <ResetPassword />} />
<Route path="/review/:token" element={<PublicReview />} /> <Route path="/review/:token" element={<PublicReview />} />
<Route path="/review-post/:token" element={<PublicPostReview />} />
<Route path="/submit-issue" element={<PublicIssueSubmit />} /> <Route path="/submit-issue" element={<PublicIssueSubmit />} />
<Route path="/track/:token" element={<PublicIssueTracker />} /> <Route path="/track/:token" element={<PublicIssueTracker />} />
<Route path="/" element={user ? <Layout /> : <Navigate to="/login" replace />}> <Route path="/" element={user ? <Layout /> : <Navigate to="/login" replace />}>

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useRef } from 'react' 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 { useLanguage } from '../i18n/LanguageContext'
import { api, PLATFORMS, getBrandColor } from '../utils/api' import { api, PLATFORMS, getBrandColor } from '../utils/api'
import ApproverMultiSelect from './ApproverMultiSelect' import ApproverMultiSelect from './ApproverMultiSelect'
@@ -7,9 +7,11 @@ import CommentsSection from './CommentsSection'
import Modal from './Modal' import Modal from './Modal'
import SlidePanel from './SlidePanel' import SlidePanel from './SlidePanel'
import CollapsibleSection from './CollapsibleSection' import CollapsibleSection from './CollapsibleSection'
import { useToast } from './ToastContainer'
export default function PostDetailPanel({ post, onClose, onSave, onDelete, brands, teamMembers, campaigns }) { export default function PostDetailPanel({ post, onClose, onSave, onDelete, brands, teamMembers, campaigns }) {
const { t, lang } = useLanguage() const { t, lang } = useLanguage()
const toast = useToast()
const imageInputRef = useRef(null) const imageInputRef = useRef(null)
const audioInputRef = useRef(null) const audioInputRef = useRef(null)
const videoInputRef = useRef(null) const videoInputRef = useRef(null)
@@ -19,6 +21,11 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
const [publishError, setPublishError] = useState('') const [publishError, setPublishError] = useState('')
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
// Review state
const [submittingReview, setSubmittingReview] = useState(false)
const [reviewUrl, setReviewUrl] = useState('')
const [copied, setCopied] = useState(false)
// Attachments state // Attachments state
const [attachments, setAttachments] = useState([]) const [attachments, setAttachments] = useState([])
const [uploading, setUploading] = useState(false) 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 () => { const confirmDelete = async () => {
setShowDeleteConfirm(false) setShowDeleteConfirm(false)
await onDelete(postId) await onDelete(postId)
@@ -403,47 +435,73 @@ export default function PostDetailPanel({ post, onClose, onSave, onDelete, brand
</div> </div>
{!isCreateMode && ( {!isCreateMode && (
<div className="flex gap-2 flex-wrap"> <>
{/* Submit for Review */}
{(form.status === 'draft' || form.status === 'rejected') && ( {(form.status === 'draft' || form.status === 'rejected') && (
<button <button
onClick={() => handleStatusAction('in_review')} onClick={handleSubmitReview}
disabled={saving} disabled={submittingReview}
className="flex items-center gap-1.5 px-3 py-1.5 bg-amber-600 text-white rounded-lg text-xs font-medium hover:bg-amber-700 disabled:opacity-50" className="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-amber-500 text-white rounded-lg hover:bg-amber-600 transition-colors font-medium text-sm disabled:opacity-50"
> >
<Send className="w-3.5 h-3.5" /> <Send className="w-4 h-4" />
{t('posts.sendToReview')} {submittingReview ? t('posts.submitting') : t('posts.sendToReview')}
</button> </button>
)} )}
{form.status === 'in_review' && (
<> {/* Review Link */}
<button {reviewUrl && (
onClick={() => handleStatusAction('approved')} <div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
disabled={saving} <div className="text-xs font-semibold text-blue-900 mb-2">{t('posts.reviewLinkTitle')}</div>
className="flex items-center gap-1.5 px-3 py-1.5 bg-emerald-600 text-white rounded-lg text-xs font-medium hover:bg-emerald-700 disabled:opacity-50" <div className="flex items-center gap-2">
> <input type="text" value={reviewUrl} readOnly className="flex-1 px-3 py-1.5 text-xs bg-white border border-border rounded" />
<CheckCircle2 className="w-3.5 h-3.5" /> <button onClick={copyReviewLink} className="p-2 bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors">
{t('posts.approve')} {copied ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />}
</button> </button>
<button </div>
onClick={() => handleStatusAction('rejected')} </div>
disabled={saving}
className="flex items-center gap-1.5 px-3 py-1.5 bg-red-600 text-white rounded-lg text-xs font-medium hover:bg-red-700 disabled:opacity-50"
>
<XCircle className="w-3.5 h-3.5" />
{t('posts.reject')}
</button>
</>
)} )}
{/* Waiting for review */}
{form.status === 'in_review' && (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3 text-center">
<p className="text-sm font-medium text-amber-800">{t('posts.awaitingReview')}</p>
<p className="text-xs text-amber-600 mt-1">{t('posts.awaitingReviewDesc')}</p>
</div>
)}
{/* Approved info */}
{form.status === 'approved' && post.approved_by_name && (
<div className="bg-emerald-50 border border-emerald-200 rounded-lg p-3">
<div className="flex items-center gap-2 mb-1">
<CheckCircle2 className="w-4 h-4 text-emerald-600" />
<span className="text-sm font-medium text-emerald-800">{t('posts.approvedBy')} {post.approved_by_name}</span>
</div>
{post.feedback && <p className="text-xs text-emerald-700 mt-1">{post.feedback}</p>}
</div>
)}
{/* Rejected info */}
{form.status === 'rejected' && post.approved_by_name && (
<div className="bg-red-50 border border-red-200 rounded-lg p-3">
<div className="flex items-center gap-2 mb-1">
<XCircle className="w-4 h-4 text-red-600" />
<span className="text-sm font-medium text-red-800">{t('posts.rejectedBy')} {post.approved_by_name}</span>
</div>
{post.feedback && <p className="text-xs text-red-700 mt-1">{post.feedback}</p>}
</div>
)}
{/* Schedule button after approval */}
{form.status === 'approved' && ( {form.status === 'approved' && (
<button <button
onClick={() => handleStatusAction('scheduled')} onClick={() => handleStatusAction('scheduled')}
disabled={saving} disabled={saving}
className="flex items-center gap-1.5 px-3 py-1.5 bg-purple-600 text-white rounded-lg text-xs font-medium hover:bg-purple-700 disabled:opacity-50" className="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors font-medium text-sm disabled:opacity-50"
> >
{t('posts.schedule')} {t('posts.schedule')}
</button> </button>
)} )}
</div> </>
)} )}
</div> </div>
</CollapsibleSection> </CollapsibleSection>

View File

@@ -889,5 +889,18 @@
"posts.selectApprovers": "اختر المعتمدين...", "posts.selectApprovers": "اختر المعتمدين...",
"posts.scheduling": "الجدولة والتعيين", "posts.scheduling": "الجدولة والتعيين",
"posts.content": "المحتوى", "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": "أنشئ بواسطة"
} }

View File

@@ -889,5 +889,18 @@
"posts.selectApprovers": "Select approvers...", "posts.selectApprovers": "Select approvers...",
"posts.scheduling": "Scheduling & Assignment", "posts.scheduling": "Scheduling & Assignment",
"posts.content": "Content", "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"
} }

View File

@@ -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 (
<div className="min-h-screen bg-surface-secondary flex items-center justify-center">
<div className="max-w-3xl w-full mx-auto px-4 space-y-6 animate-pulse">
<div className="bg-surface rounded-2xl overflow-hidden">
<div className="h-24 bg-surface-tertiary" />
<div className="p-8 space-y-4">
<div className="h-6 bg-surface-tertiary rounded w-2/3" />
<div className="h-4 bg-surface-tertiary rounded w-1/2" />
<div className="h-32 bg-surface-tertiary rounded" />
</div>
</div>
</div>
</div>
)
}
if (error) {
return (
<div className="min-h-screen bg-surface-secondary flex items-center justify-center p-4">
<div className="max-w-md w-full bg-surface rounded-2xl shadow-sm p-8 text-center">
<div className="w-16 h-16 rounded-full bg-red-100 flex items-center justify-center mx-auto mb-4">
<XCircle className="w-8 h-8 text-red-600" />
</div>
<h2 className="text-2xl font-bold text-text-primary mb-2">{t('review.notAvailable')}</h2>
<p className="text-text-secondary">{error}</p>
</div>
</div>
)
}
if (success) {
return (
<div className="min-h-screen bg-surface-secondary flex items-center justify-center p-4">
<div className="max-w-md w-full bg-surface rounded-2xl shadow-sm p-8 text-center">
<div className="w-16 h-16 rounded-full bg-emerald-100 flex items-center justify-center mx-auto mb-4">
<CheckCircle className="w-8 h-8 text-emerald-600" />
</div>
<h2 className="text-2xl font-bold text-text-primary mb-2">{t('review.thankYou')}</h2>
<p className="text-text-secondary">{success}</p>
</div>
</div>
)
}
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 (
<div className="min-h-screen bg-surface-secondary py-12 px-4">
<div className="max-w-3xl mx-auto">
{/* Header */}
<div className="bg-surface rounded-2xl shadow-sm overflow-hidden mb-6">
<div className="bg-brand-primary px-8 py-6">
<div className="flex items-center gap-3 mb-2">
<div className="w-10 h-10 rounded-xl bg-white/20 flex items-center justify-center">
<Sparkles className="w-6 h-6 text-white" />
</div>
<div>
<h1 className="text-2xl font-bold text-white">{t('review.postReview')}</h1>
<p className="text-white/80 text-sm">Samaya Digital Hub</p>
</div>
</div>
</div>
<div className="p-8">
{/* Post Info */}
<div className="mb-6">
<h2 className="text-2xl font-bold text-text-primary mb-2">{post.title}</h2>
{post.description && (
<p className="text-text-secondary whitespace-pre-wrap mb-3">{post.description}</p>
)}
<div className="flex items-center gap-3 text-sm text-text-tertiary flex-wrap">
{post.brand_name && (
<span className="px-2 py-0.5 bg-brand-primary/10 text-brand-primary rounded-full text-xs font-medium">{post.brand_name}</span>
)}
{platforms.length > 0 && (
<span className="px-2 py-0.5 bg-surface-tertiary rounded-full text-xs">{platforms.join(', ')}</span>
)}
{post.creator_name && <span>{t('review.createdBy')} {post.creator_name}</span>}
{post.scheduled_date && <span> {new Date(post.scheduled_date).toLocaleDateString()}</span>}
</div>
</div>
{/* Images */}
{images.length > 0 && (
<div className="mb-6">
<div className="flex items-center gap-2 mb-3">
<ImageIcon className="w-4 h-4 text-text-tertiary" />
<h3 className="text-sm font-semibold text-text-tertiary uppercase">{t('posts.images')}</h3>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{images.map((att, idx) => (
<a key={idx} href={att.url} target="_blank" rel="noopener noreferrer"
className="block rounded-xl overflow-hidden border-2 border-border hover:border-brand-primary transition-colors shadow-sm">
<img src={att.url} alt={att.original_name || `Image ${idx + 1}`} className="w-full h-64 object-cover" />
{att.original_name && (
<div className="bg-surface-secondary px-4 py-2 border-t border-border">
<p className="text-sm text-text-secondary truncate">{att.original_name}</p>
</div>
)}
</a>
))}
</div>
</div>
)}
{/* Videos */}
{videos.length > 0 && (
<div className="mb-6">
<div className="flex items-center gap-2 mb-3">
<Film className="w-4 h-4 text-text-tertiary" />
<h3 className="text-sm font-semibold text-text-tertiary uppercase">{t('posts.videos')}</h3>
</div>
<div className="space-y-4">
{videos.map((att, idx) => (
<div key={idx} className="bg-surface-secondary rounded-xl overflow-hidden border border-border">
{att.original_name && (
<div className="px-4 py-2 bg-surface border-b border-border">
<span className="text-sm font-medium text-text-secondary">{att.original_name}</span>
</div>
)}
<video src={att.url} controls className="w-full" />
</div>
))}
</div>
</div>
)}
{/* Audio */}
{audio.length > 0 && (
<div className="mb-6">
<div className="flex items-center gap-2 mb-3">
<Music className="w-4 h-4 text-text-tertiary" />
<h3 className="text-sm font-semibold text-text-tertiary uppercase">{t('posts.audio')}</h3>
</div>
<div className="space-y-3">
{audio.map((att, idx) => (
<div key={idx} className="flex items-center gap-3 p-3 bg-surface-secondary rounded-xl border border-border">
<Music className="w-5 h-5 text-text-tertiary shrink-0" />
<span className="text-sm text-text-secondary truncate flex-1">{att.original_name}</span>
<audio src={att.url} controls className="h-8 max-w-[200px]" />
</div>
))}
</div>
</div>
)}
{/* Other files */}
{others.length > 0 && (
<div className="mb-6">
<h3 className="text-sm font-semibold text-text-tertiary uppercase mb-3">{t('posts.otherFiles')}</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{others.map((att, idx) => (
<a key={idx} href={att.url} target="_blank" rel="noopener noreferrer"
className="flex items-center gap-3 p-4 bg-surface-secondary rounded-xl border border-border hover:border-brand-primary transition-colors">
<FileText className="w-8 h-8 text-text-tertiary shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-text-primary truncate">{att.original_name}</p>
{att.size && <p className="text-xs text-text-tertiary">{(att.size / 1024).toFixed(1)} KB</p>}
</div>
</a>
))}
</div>
</div>
)}
{/* Review Form */}
{post.status === 'in_review' && (
<div className="border-t border-border pt-6">
<h3 className="text-lg font-semibold text-text-primary mb-4">{t('review.yourReview')}</h3>
<div className="space-y-4 mb-6">
<div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('review.reviewer')}</label>
{post.approvers?.length === 1 ? (
<div className="flex items-center gap-2 px-4 py-2 bg-surface-secondary border border-border rounded-lg">
<User className="w-4 h-4 text-text-tertiary" />
<span className="text-sm text-text-primary">{post.approvers[0].name}</span>
</div>
) : post.approvers?.length > 1 ? (
<select value={reviewerName} onChange={e => setReviewerName(e.target.value)}
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">
<option value="">{t('review.selectYourName')}</option>
{post.approvers.map(a => <option key={a.id} value={a.name}>{a.name}</option>)}
</select>
) : (
<input type="text" value={reviewerName} onChange={e => 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" />
)}
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('review.feedbackOptional')}</label>
<textarea value={feedback} onChange={e => setFeedback(e.target.value)} rows={4}
placeholder={t('review.feedbackPlaceholder')}
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" />
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<button onClick={() => handleAction('approve')} disabled={submitting}
className="flex items-center justify-center gap-2 px-4 py-3 bg-emerald-600 text-white rounded-xl font-medium hover:bg-emerald-700 transition-colors disabled:opacity-50 shadow-sm">
<CheckCircle className="w-5 h-5" />
{t('review.approve')}
</button>
<button onClick={() => handleAction('reject')} disabled={submitting}
className="flex items-center justify-center gap-2 px-4 py-3 bg-red-600 text-white rounded-xl font-medium hover:bg-red-700 transition-colors disabled:opacity-50 shadow-sm">
<XCircle className="w-5 h-5" />
{t('review.reject')}
</button>
</div>
</div>
)}
{/* Already Reviewed */}
{post.status !== 'in_review' && (
<div className="border-t border-border pt-6">
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4 text-center">
<p className="text-blue-900 font-medium">{t('review.alreadyReviewed')}</p>
<p className="text-blue-700 text-sm mt-1">
{t('review.statusLabel')}: <span className="font-semibold capitalize">{post.status.replace('_', ' ')}</span>
</p>
{post.approved_by_name && (
<p className="text-blue-700 text-sm mt-1">
{t('review.reviewedBy')}: <span className="font-semibold">{post.approved_by_name}</span>
</p>
)}
{post.feedback && (
<p className="text-blue-700 text-sm mt-2 italic">"{post.feedback}"</p>
)}
</div>
</div>
)}
</div>
</div>
<div className="text-center text-text-tertiary text-sm">
<p>{t('review.poweredBy')}</p>
</div>
</div>
<Modal
isOpen={!!pendingAction}
onClose={() => 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')}
</Modal>
</div>
)
}

View File

@@ -462,7 +462,14 @@ const TEXT_COLUMNS = {
Comments: [{ name: 'version_number', uidt: 'Number' }], Comments: [{ name: 'version_number', uidt: 'Number' }],
Issues: [{ name: 'thumbnail', uidt: 'SingleLineText' }], Issues: [{ name: 'thumbnail', uidt: 'SingleLineText' }],
Artefacts: [{ name: 'approver_ids', 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() { 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.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; 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 // Publish validation
if (req.body.status === 'published') { if (req.body.status === 'published') {
let currentPlatforms, currentLinks; 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 ───────────────────────────────────────────────────── // ─── ASSETS ─────────────────────────────────────────────────────
app.get('/api/assets', requireAuth, async (req, res) => { app.get('/api/assets', requireAuth, async (req, res) => {