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:
@@ -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() {
|
||||
<Route path="/forgot-password" element={user ? <Navigate to="/" replace /> : <ForgotPassword />} />
|
||||
<Route path="/reset-password" element={user ? <Navigate to="/" replace /> : <ResetPassword />} />
|
||||
<Route path="/review/:token" element={<PublicReview />} />
|
||||
<Route path="/review-post/:token" element={<PublicPostReview />} />
|
||||
<Route path="/submit-issue" element={<PublicIssueSubmit />} />
|
||||
<Route path="/track/:token" element={<PublicIssueTracker />} />
|
||||
<Route path="/" element={user ? <Layout /> : <Navigate to="/login" replace />}>
|
||||
|
||||
@@ -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
|
||||
</div>
|
||||
|
||||
{!isCreateMode && (
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<>
|
||||
{/* Submit for Review */}
|
||||
{(form.status === 'draft' || form.status === 'rejected') && (
|
||||
<button
|
||||
onClick={() => handleStatusAction('in_review')}
|
||||
disabled={saving}
|
||||
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"
|
||||
onClick={handleSubmitReview}
|
||||
disabled={submittingReview}
|
||||
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" />
|
||||
{t('posts.sendToReview')}
|
||||
<Send className="w-4 h-4" />
|
||||
{submittingReview ? t('posts.submitting') : t('posts.sendToReview')}
|
||||
</button>
|
||||
)}
|
||||
{form.status === 'in_review' && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => handleStatusAction('approved')}
|
||||
disabled={saving}
|
||||
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"
|
||||
>
|
||||
<CheckCircle2 className="w-3.5 h-3.5" />
|
||||
{t('posts.approve')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleStatusAction('rejected')}
|
||||
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>
|
||||
</>
|
||||
|
||||
{/* Review Link */}
|
||||
{reviewUrl && (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
|
||||
<div className="text-xs font-semibold text-blue-900 mb-2">{t('posts.reviewLinkTitle')}</div>
|
||||
<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" />
|
||||
<button onClick={copyReviewLink} className="p-2 bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors">
|
||||
{copied ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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' && (
|
||||
<button
|
||||
onClick={() => handleStatusAction('scheduled')}
|
||||
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')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
@@ -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": "أنشئ بواسطة"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
341
client/src/pages/PublicPostReview.jsx
Normal file
341
client/src/pages/PublicPostReview.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
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