feat: artefact version auto-advance, post_id on artefacts, test-email endpoint
Deploy / deploy (push) Successful in 13s

- Auto-advance artefact to next working version on rejection/revision
- Add post_id field to artefact creation
- Add request timeout (20s) to NocoDB client
- Add POST /api/admin/test-email for diagnosing SMTP issues
- Fix FK column creation logging

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
fahed
2026-05-11 12:27:23 +03:00
parent a67b2afb0d
commit 94ce012837
7 changed files with 391 additions and 276 deletions
+110 -114
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useContext, useCallback } from 'react'
import { useState, useEffect, useContext, useCallback, useRef } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { ArrowLeft, Save, FileText, Image as ImageIcon, Film, Type, Search, Link2, Unlink, Plus, CheckCircle, Clock, X, ExternalLink } from 'lucide-react'
import { AppContext } from '../App'
@@ -14,6 +14,20 @@ import { useToast } from '../components/ToastContainer'
const STATUS_OPTS = ['draft', 'in_review', 'approved', 'rejected', 'scheduled', 'published']
// Maps asset type key → composition field name
const PIECE_MAP = { caption: 'caption', body: 'body_copy', design: 'design', video: 'video' }
// Maps asset type key → i18n label key
const LABEL_KEYS = {
caption: 'postDetail.captionCopy',
body: 'postDetail.bodyCopy',
design: 'postDetail.design',
video: 'postDetail.video',
}
const ASSET_ICONS = { caption: Type, body: FileText, design: ImageIcon, video: Film }
const ASSET_TYPES = ['caption', 'body', 'design', 'video']
// Maps server-generated waiting_on labels → asset type key
const WAITING_TYPE_MAP = { Caption: 'caption', Copy: 'body', Design: 'design', Video: 'video' }
export default function PostDetail() {
const { id } = useParams()
const navigate = useNavigate()
@@ -37,21 +51,18 @@ export default function PostDetail() {
const [platforms, setPlatforms] = useState([])
const [scheduledDate, setScheduledDate] = useState('')
// Link pickers
// Link pickers / create
const [creating, setCreating] = useState(false)
const [activePicker, setActivePicker] = useState(null) // 'caption' | 'body' | 'design' | 'video'
const [pickerSearch, setPickerSearch] = useState('')
const [linkCandidates, setLinkCandidates] = useState([])
const [linking, setLinking] = useState(false)
const allArtefactsRef = useRef(null)
// Sub-panels
const [openArtefact, setOpenArtefact] = useState(null)
useEffect(() => {
loadPost()
api.get('/campaigns').then(r => setCampaigns(Array.isArray(r) ? r : [])).catch(() => {})
}, [id])
const loadPost = async () => {
const loadPost = useCallback(async () => {
try {
const [p, comp] = await Promise.all([
api.get(`/posts/${id}`),
@@ -73,7 +84,12 @@ export default function PostDetail() {
} finally {
setLoading(false)
}
}
}, [id])
useEffect(() => {
loadPost()
api.get('/campaigns').then(r => setCampaigns(Array.isArray(r) ? r : [])).catch(() => {})
}, [loadPost])
const loadComposition = useCallback(async () => {
try {
@@ -96,7 +112,8 @@ export default function PostDetail() {
scheduled_date: scheduledDate || null,
})
toast.success(t('posts.updated'))
loadPost()
// Update local post state — composition is unaffected by metadata changes
setPost(p => ({ ...p, title, status, brand_id: brandId, campaign_id: campaignId, assigned_to: assignedTo, platforms, scheduled_date: scheduledDate || null }))
} catch {
toast.error(t('common.saveFailed'))
} finally {
@@ -110,20 +127,22 @@ export default function PostDetail() {
// ─── Link / Unlink / Create ───
const TYPE_FILTERS = {
caption: a => a.type === 'copy' && a.copy_type === 'caption',
body: a => a.type === 'copy' && (a.copy_type === 'body' || !a.copy_type),
video: a => a.type === 'video',
design: a => (a.type || 'design') === 'design',
}
const openLinkPicker = async (type) => {
setActivePicker(type)
setPickerSearch('')
try {
const all = await api.get('/artefacts')
let typeFilter
if (type === 'caption') typeFilter = a => a.type === 'copy' && a.copy_type === 'caption'
else if (type === 'body') typeFilter = a => a.type === 'copy' && (a.copy_type === 'body' || !a.copy_type)
else if (type === 'video') typeFilter = a => a.type === 'video'
else typeFilter = a => (a.type || 'design') === 'design'
setLinkCandidates((Array.isArray(all) ? all : []).filter(a => {
if (!allArtefactsRef.current) allArtefactsRef.current = await api.get('/artefacts')
const all = Array.isArray(allArtefactsRef.current) ? allArtefactsRef.current : []
setLinkCandidates(all.filter(a => {
const linkedTo = a.post_id || a.postId
return typeFilter(a) && (!linkedTo || String(linkedTo) !== String(id))
return TYPE_FILTERS[type](a) && (!linkedTo || String(linkedTo) !== String(id))
}))
} catch {
setLinkCandidates([])
@@ -135,6 +154,7 @@ export default function PostDetail() {
setLinking(true)
try {
await api.patch(`/artefacts/${itemId}`, { post_id: Number(id) })
allArtefactsRef.current = null
toast.success(t('posts.updated'))
setActivePicker(null)
loadComposition()
@@ -146,13 +166,11 @@ export default function PostDetail() {
}
const handleUnlink = async (type) => {
const piece = type === 'caption' ? composition?.caption
: type === 'body' ? composition?.body_copy
: type === 'design' ? composition?.design
: composition?.video
const piece = composition?.[PIECE_MAP[type]]
if (!piece) return
try {
await api.patch(`/artefacts/${piece.id}`, { post_id: null })
allArtefactsRef.current = null
toast.success(t('posts.updated'))
loadComposition()
} catch {
@@ -161,10 +179,7 @@ export default function PostDetail() {
}
const handleOpenPiece = async (type) => {
const piece = type === 'caption' ? composition?.caption
: type === 'body' ? composition?.body_copy
: type === 'design' ? composition?.design
: composition?.video
const piece = composition?.[PIECE_MAP[type]]
if (!piece) return
try {
const full = await api.get(`/artefacts/${piece.id}`)
@@ -173,21 +188,22 @@ export default function PostDetail() {
}
const handleCreate = async (type) => {
const label = type === 'caption' ? t('postDetail.captionCopy')
: type === 'body' ? t('postDetail.bodyCopy')
: type === 'design' ? t('postDetail.design')
: t('postDetail.video')
if (creating) return
setCreating(true)
try {
const created = await api.post('/artefacts', {
title: `${label}${title}`,
title: title.trim() ? `${t(LABEL_KEYS[type])}${title.trim()}` : t(LABEL_KEYS[type]),
type: type === 'caption' || type === 'body' ? 'copy' : type,
copy_type: type === 'caption' ? 'caption' : type === 'body' ? 'body' : undefined,
post_id: Number(id),
})
allArtefactsRef.current = null
setOpenArtefact(created)
loadComposition()
} catch (err) {
} catch {
toast.error(t('common.saveFailed'))
} finally {
setCreating(false)
}
}
@@ -224,6 +240,16 @@ export default function PostDetail() {
return (c.title || '').toLowerCase().includes(pickerSearch.toLowerCase())
})
const isDirty = Boolean(post) && (
title !== (post.title || '') ||
status !== (post.status || 'draft') ||
String(brandId) !== String(post.brand_id || post.brandId || '') ||
String(campaignId) !== String(post.campaign_id || post.campaignId || '') ||
String(assignedTo) !== String(post.assigned_to || post.assignedTo || '') ||
JSON.stringify(platforms) !== JSON.stringify(Array.isArray(post.platforms) ? post.platforms : (post.platform ? [post.platform] : [])) ||
scheduledDate !== ((post.scheduled_date || post.scheduledDate) ? new Date(post.scheduled_date || post.scheduledDate).toISOString().slice(0, 10) : '')
)
const waitingOn = composition?.waiting_on || []
const piecesReady = composition?.pieces_ready || false
const hasPieces = composition?.caption || composition?.body_copy || composition?.design || composition?.video
@@ -316,88 +342,42 @@ export default function PostDetail() {
<button
onClick={handleSave}
disabled={saving}
className="flex items-center gap-1.5 px-4 py-1.5 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light shadow-sm disabled:opacity-50"
className={`flex items-center gap-1.5 px-4 py-1.5 rounded-lg text-sm font-medium shadow-sm disabled:opacity-50 transition-colors ${
isDirty ? 'bg-amber-500 hover:bg-amber-600 text-white' : 'bg-brand-primary hover:bg-brand-primary-light text-white'
}`}
>
<Save className="w-4 h-4" />
{saving ? t('common.loading') : t('common.save')}
{isDirty && !saving && <span className="w-1.5 h-1.5 rounded-full bg-white/70 ms-0.5" />}
</button>
</div>
</div>
{/* ─── ASSET CARDS ─── */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<AssetCard
type="caption"
label={t('postDetail.captionCopy')}
icon={Type}
piece={composition?.caption}
onCreate={() => handleCreate('caption')}
onOpen={() => handleOpenPiece('caption')}
onUnlink={() => handleUnlink('caption')}
onOpenPicker={() => openLinkPicker('caption')}
activePicker={activePicker}
pickerSearch={pickerSearch}
filteredCandidates={filteredCandidates}
linking={linking}
onLink={handleLink}
onPickerSearchChange={setPickerSearch}
onClosePicker={() => setActivePicker(null)}
t={t}
/>
<AssetCard
type="body"
label={t('postDetail.bodyCopy')}
icon={FileText}
piece={composition?.body_copy}
onCreate={() => handleCreate('body')}
onOpen={() => handleOpenPiece('body')}
onUnlink={() => handleUnlink('body')}
onOpenPicker={() => openLinkPicker('body')}
activePicker={activePicker}
pickerSearch={pickerSearch}
filteredCandidates={filteredCandidates}
linking={linking}
onLink={handleLink}
onPickerSearchChange={setPickerSearch}
onClosePicker={() => setActivePicker(null)}
t={t}
/>
<AssetCard
type="design"
label={t('postDetail.design')}
icon={ImageIcon}
piece={composition?.design}
onCreate={() => handleCreate('design')}
onOpen={() => handleOpenPiece('design')}
onUnlink={() => handleUnlink('design')}
onOpenPicker={() => openLinkPicker('design')}
activePicker={activePicker}
pickerSearch={pickerSearch}
filteredCandidates={filteredCandidates}
linking={linking}
onLink={handleLink}
onPickerSearchChange={setPickerSearch}
onClosePicker={() => setActivePicker(null)}
t={t}
/>
<AssetCard
type="video"
label={t('postDetail.video')}
icon={Film}
piece={composition?.video}
onCreate={() => handleCreate('video')}
onOpen={() => handleOpenPiece('video')}
onUnlink={() => handleUnlink('video')}
onOpenPicker={() => openLinkPicker('video')}
activePicker={activePicker}
pickerSearch={pickerSearch}
filteredCandidates={filteredCandidates}
linking={linking}
onLink={handleLink}
onPickerSearchChange={setPickerSearch}
onClosePicker={() => setActivePicker(null)}
t={t}
/>
{ASSET_TYPES.map(type => (
<AssetCard
key={type}
id={`asset-${type}`}
type={type}
label={t(LABEL_KEYS[type])}
icon={ASSET_ICONS[type]}
piece={composition?.[PIECE_MAP[type]]}
onCreate={() => handleCreate(type)}
creating={creating}
onOpen={() => handleOpenPiece(type)}
onUnlink={() => handleUnlink(type)}
onOpenPicker={() => openLinkPicker(type)}
activePicker={activePicker}
pickerSearch={pickerSearch}
filteredCandidates={filteredCandidates}
linking={linking}
onLink={handleLink}
onPickerSearchChange={setPickerSearch}
onClosePicker={() => setActivePicker(null)}
t={t}
/>
))}
</div>
{/* ─── READINESS ─── */}
@@ -411,9 +391,23 @@ export default function PostDetail() {
<span className="text-sm font-medium">{t('postDetail.allPiecesApproved')}</span>
</div>
) : (
<div className="flex items-center gap-2 text-amber-600">
<Clock className="w-5 h-5" />
<span className="text-sm font-medium">{t('postDetail.waitingOn')}: {waitingOn.join(', ')}</span>
<div className="flex items-start gap-2 text-amber-600">
<Clock className="w-5 h-5 shrink-0 mt-0.5" />
<div className="flex flex-wrap gap-1.5 items-center">
<span className="text-sm font-medium">{t('postDetail.waitingOn')}:</span>
{waitingOn.map(label => {
const type = WAITING_TYPE_MAP[label]
return type ? (
<button
key={label}
onClick={() => document.getElementById(`asset-${type}`)?.scrollIntoView({ behavior: 'smooth', block: 'center' })}
className="text-xs px-2 py-0.5 rounded-full bg-amber-100 text-amber-700 hover:bg-amber-200 transition-colors font-medium"
>
{label}
</button>
) : <span key={label} className="text-sm">{label}</span>
})}
</div>
</div>
)}
</div>
@@ -427,6 +421,7 @@ export default function PostDetail() {
{/* ─── SUB-PANELS (they render their own SlidePanel internally) ─── */}
{openArtefact && (
<ArtefactDetailPanel
key={openArtefact._id}
artefact={openArtefact}
onClose={() => { setOpenArtefact(null); loadComposition() }}
onUpdate={loadComposition}
@@ -441,8 +436,8 @@ export default function PostDetail() {
// ─── Asset Card Component ───
function AssetCard({
type, label, icon: Icon, piece,
onCreate, onOpen, onUnlink,
id, type, label, icon: Icon, piece,
onCreate, creating, onOpen, onUnlink,
onOpenPicker, activePicker, pickerSearch, filteredCandidates, linking,
onLink, onPickerSearchChange, onClosePicker, t,
}) {
@@ -453,7 +448,7 @@ function AssetCard({
const isApproved = piece?.status === 'approved'
return (
<div className="bg-surface rounded-xl border border-border p-4 flex flex-col">
<div id={id} className="bg-surface rounded-xl border border-border p-4 flex flex-col">
{/* Header */}
<div className="flex items-center gap-2 mb-3">
<Icon className="w-4 h-4 text-text-tertiary" />
@@ -561,10 +556,11 @@ function AssetCard({
</button>
<button
onClick={onCreate}
className="flex items-center gap-1 px-2.5 py-1 text-xs font-medium text-brand-primary hover:bg-brand-primary/10 rounded-lg transition-colors"
disabled={creating}
className="flex items-center gap-1 px-2.5 py-1 text-xs font-medium text-brand-primary hover:bg-brand-primary/10 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<Plus className="w-3.5 h-3.5" />
{t('postDetail.createNew')}
{creating ? t('common.loading') : t('postDetail.createNew')}
</button>
</div>
)}