refactor: unify post composition — all assets are artefacts

PostDetail now uses Artefacts exclusively for all 4 asset types:
- Caption copy = Artefact with type='copy', copy_type='caption'
- Body copy = Artefact with type='copy', copy_type='body'
- Design = Artefact with type='design'
- Video = Artefact with type='video'

Removed TranslationDetailPanel from PostDetail entirely.
Same ArtefactDetailPanel, same workflow, same version management
for all asset types. Link picker searches artefacts only.

Server: copy_type added to Artefacts schema, accepted in POST/PATCH.
post-composition.js rewritten to use Artefacts table for all pieces.
Content preview fetched from ArtefactVersionTexts.

Translations entity still exists for the standalone Copy page.

Also: version creation rules, submit-review content validation,
reviewer mandatory for translations, artefact creation simplified.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
fahed
2026-03-16 17:15:21 +03:00
parent 16a94a2f19
commit 378d91648b
6 changed files with 106 additions and 105 deletions
@@ -105,6 +105,7 @@ export function ArtefactDetailVersionsTab({
<div> <div>
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<h4 className="text-xs font-semibold text-text-tertiary uppercase">{t('artefacts.versions')}</h4> <h4 className="text-xs font-semibold text-text-tertiary uppercase">{t('artefacts.versions')}</h4>
{artefact.status === 'rejected' && (
<button <button
onClick={() => setShowNewVersionModal(true)} onClick={() => setShowNewVersionModal(true)}
className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light transition-colors" className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light transition-colors"
@@ -112,7 +113,18 @@ export function ArtefactDetailVersionsTab({
<Plus className="w-3 h-3" /> <Plus className="w-3 h-3" />
{t('artefacts.newVersion')} {t('artefacts.newVersion')}
</button> </button>
)}
</div> </div>
{artefact.status === 'rejected' && (
<div className="mb-3 px-3 py-2 bg-red-50 border border-red-200 rounded-lg text-xs text-red-700">
{t('artefacts.rejectedMustCreateNewVersion')}
</div>
)}
{artefact.status === 'revision_requested' && (
<div className="mb-3 px-3 py-2 bg-amber-50 border border-amber-200 rounded-lg text-xs text-amber-700">
{t('artefacts.revisionEditCurrentVersion')}
</div>
)}
<ArtefactVersionTimeline <ArtefactVersionTimeline
versions={versions} versions={versions}
activeVersionId={selectedVersion?.Id} activeVersionId={selectedVersion?.Id}
+2
View File
@@ -609,6 +609,8 @@
"artefacts.selectVersionFirst": "اختر إصداراً لعرض التعليقات.", "artefacts.selectVersionFirst": "اختر إصداراً لعرض التعليقات.",
"artefacts.pendingReviewInfo": "هذا العنصر قيد المراجعة حالياً.", "artefacts.pendingReviewInfo": "هذا العنصر قيد المراجعة حالياً.",
"artefacts.noReviewInfo": "لا توجد معلومات مراجعة متاحة.", "artefacts.noReviewInfo": "لا توجد معلومات مراجعة متاحة.",
"artefacts.rejectedMustCreateNewVersion": "تم رفض هذا العنصر. أنشئ إصداراً جديداً لمعالجة الملاحظات.",
"artefacts.revisionEditCurrentVersion": "طُلب تعديل — عدّل الإصدار الحالي وأعد إرساله للمراجعة.",
"artefacts.grid": "شبكة", "artefacts.grid": "شبكة",
"artefacts.list": "قائمة", "artefacts.list": "قائمة",
"artefacts.allCreators": "جميع المنشئين", "artefacts.allCreators": "جميع المنشئين",
+2
View File
@@ -609,6 +609,8 @@
"artefacts.selectVersionFirst": "Select a version to view comments.", "artefacts.selectVersionFirst": "Select a version to view comments.",
"artefacts.pendingReviewInfo": "This artefact is currently pending review.", "artefacts.pendingReviewInfo": "This artefact is currently pending review.",
"artefacts.noReviewInfo": "No review information available.", "artefacts.noReviewInfo": "No review information available.",
"artefacts.rejectedMustCreateNewVersion": "This artefact was rejected. Create a new version to address the feedback.",
"artefacts.revisionEditCurrentVersion": "Revision requested — edit the current version and resubmit for review.",
"artefacts.grid": "Grid", "artefacts.grid": "Grid",
"artefacts.list": "List", "artefacts.list": "List",
"artefacts.allCreators": "All Creators", "artefacts.allCreators": "All Creators",
+15 -52
View File
@@ -9,7 +9,6 @@ import PlatformIcon from '../components/PlatformIcon'
import StatusBadge from '../components/StatusBadge' import StatusBadge from '../components/StatusBadge'
import PortalSelect from '../components/PortalSelect' import PortalSelect from '../components/PortalSelect'
import CommentsSection from '../components/CommentsSection' import CommentsSection from '../components/CommentsSection'
import TranslationDetailPanel from '../components/TranslationDetailPanel'
import ArtefactDetailPanel from '../components/ArtefactDetailPanel' import ArtefactDetailPanel from '../components/ArtefactDetailPanel'
import { useToast } from '../components/ToastContainer' import { useToast } from '../components/ToastContainer'
@@ -45,7 +44,6 @@ export default function PostDetail() {
const [linking, setLinking] = useState(false) const [linking, setLinking] = useState(false)
// Sub-panels // Sub-panels
const [openTranslation, setOpenTranslation] = useState(null)
const [openArtefact, setOpenArtefact] = useState(null) const [openArtefact, setOpenArtefact] = useState(null)
useEffect(() => { useEffect(() => {
@@ -116,22 +114,17 @@ export default function PostDetail() {
setActivePicker(type) setActivePicker(type)
setPickerSearch('') setPickerSearch('')
try { try {
if (type === 'caption' || type === 'body') {
const all = await api.get('/translations')
// Show all translations not already linked to THIS post
setLinkCandidates((Array.isArray(all) ? all : []).filter(t => {
const linkedTo = t.post_id || t.postId
return !linkedTo || String(linkedTo) !== String(id)
}))
} else {
const all = await api.get('/artefacts') const all = await api.get('/artefacts')
const at = type === 'video' ? 'video' : 'design' 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 => { setLinkCandidates((Array.isArray(all) ? all : []).filter(a => {
const linkedTo = a.post_id || a.postId const linkedTo = a.post_id || a.postId
const matchesType = (a.type || 'design') === at return typeFilter(a) && (!linkedTo || String(linkedTo) !== String(id))
return matchesType && (!linkedTo || String(linkedTo) !== String(id))
})) }))
}
} catch { } catch {
setLinkCandidates([]) setLinkCandidates([])
toast.error(t('common.error')) toast.error(t('common.error'))
@@ -141,12 +134,7 @@ export default function PostDetail() {
const handleLink = async (itemId) => { const handleLink = async (itemId) => {
setLinking(true) setLinking(true)
try { try {
const copyType = activePicker === 'caption' || activePicker === 'body' ? activePicker : null
if (activePicker === 'caption' || activePicker === 'body') {
await api.patch(`/translations/${itemId}`, { post_id: Number(id), copy_type: copyType })
} else {
await api.patch(`/artefacts/${itemId}`, { post_id: Number(id) }) await api.patch(`/artefacts/${itemId}`, { post_id: Number(id) })
}
toast.success(t('posts.updated')) toast.success(t('posts.updated'))
setActivePicker(null) setActivePicker(null)
loadComposition() loadComposition()
@@ -164,8 +152,7 @@ export default function PostDetail() {
: composition?.video : composition?.video
if (!piece) return if (!piece) return
try { try {
const endpoint = (type === 'caption' || type === 'body') ? `/translations/${piece.id}` : `/artefacts/${piece.id}` await api.patch(`/artefacts/${piece.id}`, { post_id: null })
await api.patch(endpoint, { post_id: null })
toast.success(t('posts.updated')) toast.success(t('posts.updated'))
loadComposition() loadComposition()
} catch { } catch {
@@ -179,39 +166,25 @@ export default function PostDetail() {
: type === 'design' ? composition?.design : type === 'design' ? composition?.design
: composition?.video : composition?.video
if (!piece) return if (!piece) return
if (type === 'caption' || type === 'body') {
try {
const full = await api.get(`/translations/${piece.id}`)
setOpenTranslation(full)
} catch { toast.error(t('common.saveFailed')) }
} else {
try { try {
const full = await api.get(`/artefacts/${piece.id}`) const full = await api.get(`/artefacts/${piece.id}`)
setOpenArtefact(full) setOpenArtefact(full)
} catch { toast.error(t('common.saveFailed')) } } catch { toast.error(t('common.saveFailed')) }
} }
}
const handleCreate = async (type) => { const handleCreate = async (type) => {
const isCopy = type === 'caption' || type === 'body' const label = type === 'caption' ? t('postDetail.captionCopy')
: type === 'body' ? t('postDetail.bodyCopy')
: type === 'design' ? t('postDetail.design')
: t('postDetail.video')
try { try {
if (isCopy) {
const created = await api.post('/translations', {
title: `${title}${type === 'caption' ? t('postDetail.captionCopy') : t('postDetail.bodyCopy')}`,
source_language: 'AR',
source_content: '',
post_id: Number(id),
copy_type: type,
})
setOpenTranslation(created)
} else {
const created = await api.post('/artefacts', { const created = await api.post('/artefacts', {
title: `${type === 'design' ? t('postDetail.design') : t('postDetail.video')}${title}`, title: `${label}${title}`,
type: type, type: type === 'caption' || type === 'body' ? 'copy' : type,
copy_type: type === 'caption' ? 'caption' : type === 'body' ? 'body' : undefined,
post_id: Number(id), post_id: Number(id),
}) })
setOpenArtefact(created) setOpenArtefact(created)
}
loadComposition() loadComposition()
} catch (err) { } catch (err) {
toast.error(t('common.saveFailed')) toast.error(t('common.saveFailed'))
@@ -452,16 +425,6 @@ export default function PostDetail() {
</div> </div>
{/* ─── SUB-PANELS (they render their own SlidePanel internally) ─── */} {/* ─── SUB-PANELS (they render their own SlidePanel internally) ─── */}
{openTranslation && (
<TranslationDetailPanel
translation={openTranslation}
onClose={() => { setOpenTranslation(null); loadComposition() }}
onUpdate={loadComposition}
onDelete={() => { setOpenTranslation(null); loadComposition() }}
assignableUsers={teamMembers}
/>
)}
{openArtefact && ( {openArtefact && (
<ArtefactDetailPanel <ArtefactDetailPanel
artefact={openArtefact} artefact={openArtefact}
+17 -15
View File
@@ -4,15 +4,12 @@ async function getPostComposition(postId) {
const post = await nocodb.get('Posts', postId); const post = await nocodb.get('Posts', postId);
if (!post) return null; if (!post) return null;
const translations = await nocodb.list('Translations', {
where: `(post_id,eq,${postId})`, limit: 100,
});
const caption = translations.find(t => t.copy_type === 'caption') || null;
const bodyCopy = translations.find(t => t.copy_type === 'body' || !t.copy_type) || null;
const artefacts = await nocodb.list('Artefacts', { const artefacts = await nocodb.list('Artefacts', {
where: `(post_id,eq,${postId})`, limit: 100, where: `(post_id,eq,${postId})`, limit: 100,
}); });
const caption = artefacts.find(a => a.type === 'copy' && a.copy_type === 'caption') || null;
const bodyCopy = artefacts.find(a => a.type === 'copy' && (a.copy_type === 'body' || !a.copy_type)) || null;
const design = artefacts.find(a => (a.type || 'design') === 'design') || null; const design = artefacts.find(a => (a.type || 'design') === 'design') || null;
const video = artefacts.find(a => a.type === 'video') || null; const video = artefacts.find(a => a.type === 'video') || null;
@@ -28,16 +25,21 @@ async function getPostComposition(postId) {
const hasPieces = caption || bodyCopy || design || video; const hasPieces = caption || bodyCopy || design || video;
const piecesReady = hasPieces && waitingOn.length === 0; const piecesReady = hasPieces && waitingOn.length === 0;
// Get translation texts for languages preview // Get texts from ArtefactVersionTexts for copy artefacts (content preview + languages)
const getTexts = async (translationId) => { const getTexts = async (artefactId) => {
try { try {
const texts = await nocodb.list('TranslationTexts', { where: `(translation_id,eq,${translationId})`, limit: 20 }); const versions = await nocodb.list('ArtefactVersions', { where: `(artefact_id,eq,${artefactId})`, sort: '-version_number', limit: 1 });
return texts.map(tt => ({ language: tt.language_code || tt.language, status: tt.status || 'draft' })); if (versions.length === 0) return { texts: [], contentPreview: '' };
} catch { return []; } const texts = await nocodb.list('ArtefactVersionTexts', { where: `(version_id,eq,${versions[0].Id})`, limit: 20 });
const languages = texts.map(tt => ({ language: tt.language_code || tt.language, status: tt.status || 'draft' }));
const contentPreview = texts.length > 0 ? (texts[0].content || '').slice(0, 120) : '';
return { texts: languages, contentPreview };
} catch { return { texts: [], contentPreview: '' }; }
}; };
const [captionTexts, bodyTexts] = await Promise.all([ const [captionTexts, bodyTexts] = await Promise.all([
caption ? getTexts(caption.Id) : [], caption ? getTexts(caption.Id) : { texts: [], contentPreview: '' },
bodyCopy ? getTexts(bodyCopy.Id) : [], bodyCopy ? getTexts(bodyCopy.Id) : { texts: [], contentPreview: '' },
]); ]);
// Get first attachment for design/video thumbnail // Get first attachment for design/video thumbnail
@@ -75,8 +77,8 @@ async function getPostComposition(postId) {
]); ]);
return { return {
caption: caption ? { id: caption.Id, title: caption.title, status: caption.status, language: caption.source_language, content_preview: (caption.source_content || '').slice(0, 120), languages: captionTexts, ...captionApprover } : null, caption: caption ? { id: caption.Id, title: caption.title, status: caption.status, content_preview: captionTexts.contentPreview, languages: captionTexts.texts, ...captionApprover } : null,
body_copy: bodyCopy ? { id: bodyCopy.Id, title: bodyCopy.title, status: bodyCopy.status, language: bodyCopy.source_language, content_preview: (bodyCopy.source_content || '').slice(0, 120), languages: bodyTexts, ...bodyApprover } : null, body_copy: bodyCopy ? { id: bodyCopy.Id, title: bodyCopy.title, status: bodyCopy.status, content_preview: bodyTexts.contentPreview, languages: bodyTexts.texts, ...bodyApprover } : null,
design: design ? { id: design.Id, title: design.title, status: design.status, thumbnail_url: designThumb, current_version: design.current_version, ...designApprover } : null, design: design ? { id: design.Id, title: design.title, status: design.status, thumbnail_url: designThumb, current_version: design.current_version, ...designApprover } : null,
video: video ? { id: video.Id, title: video.title, status: video.status, thumbnail_url: videoThumb, current_version: video.current_version, ...videoApprover } : null, video: video ? { id: video.Id, title: video.title, status: video.status, thumbnail_url: videoThumb, current_version: video.current_version, ...videoApprover } : null,
platforms, platforms,
+38 -18
View File
@@ -521,6 +521,7 @@ const TEXT_COLUMNS = {
Artefacts: [ Artefacts: [
{ name: 'approver_ids', uidt: 'SingleLineText' }, { name: 'approver_ids', uidt: 'SingleLineText' },
{ name: 'thumbnail_url', uidt: 'SingleLineText' }, { name: 'thumbnail_url', uidt: 'SingleLineText' },
{ name: 'copy_type', uidt: 'SingleLineText' },
], ],
Posts: [ Posts: [
{ name: 'approver_ids', uidt: 'SingleLineText' }, { name: 'approver_ids', uidt: 'SingleLineText' },
@@ -4019,7 +4020,7 @@ app.get('/api/artefacts/:id', requireAuth, async (req, res) => {
}); });
app.post('/api/artefacts', requireAuth, async (req, res) => { app.post('/api/artefacts', requireAuth, async (req, res) => {
const { title, description, type, brand_id, content, project_id, campaign_id, approver_ids } = req.body; const { title, description, type, brand_id, content, project_id, campaign_id, approver_ids, copy_type } = req.body;
if (!title) return res.status(400).json({ error: 'Title is required' }); if (!title) return res.status(400).json({ error: 'Title is required' });
try { try {
@@ -4033,6 +4034,7 @@ app.post('/api/artefacts', requireAuth, async (req, res) => {
project_id: project_id ? Number(project_id) : null, project_id: project_id ? Number(project_id) : null,
campaign_id: campaign_id ? Number(campaign_id) : null, campaign_id: campaign_id ? Number(campaign_id) : null,
approver_ids: approver_ids || null, approver_ids: approver_ids || null,
copy_type: copy_type || null,
created_by_user_id: req.session.userId, created_by_user_id: req.session.userId,
current_version: 1, current_version: 1,
}; };
@@ -4112,7 +4114,7 @@ app.patch('/api/artefacts/:id', requireAuth, async (req, res) => {
} }
const data = {}; const data = {};
for (const f of ['title', 'description', 'type', 'status', 'content', 'feedback']) { for (const f of ['title', 'description', 'type', 'status', 'content', 'feedback', 'copy_type']) {
if (req.body[f] !== undefined) data[f] = req.body[f]; if (req.body[f] !== undefined) data[f] = req.body[f];
} }
if (req.body.brand_id !== undefined) data.brand_id = req.body.brand_id ? Number(req.body.brand_id) : null; if (req.body.brand_id !== undefined) data.brand_id = req.body.brand_id ? Number(req.body.brand_id) : null;
@@ -4311,12 +4313,45 @@ app.post('/api/artefacts/:id/versions', requireAuth, async (req, res) => {
return res.status(403).json({ error: 'You can only create versions for your own artefacts' }); return res.status(403).json({ error: 'You can only create versions for your own artefacts' });
} }
// Get current max version number // Get current max version
const versions = await nocodb.list('ArtefactVersions', { const versions = await nocodb.list('ArtefactVersions', {
where: `(artefact_id,eq,${sanitizeWhereValue(req.params.id)})`, where: `(artefact_id,eq,${sanitizeWhereValue(req.params.id)})`,
sort: '-version_number', sort: '-version_number',
limit: 1, limit: 1,
}); });
// Validate previous version before allowing new one
if (versions.length > 0) {
const prevVersion = versions[0];
// Check if previous version has content
if (artefact.type === 'design' || artefact.type === 'video') {
const attachments = await nocodb.list('ArtefactAttachments', {
where: `(version_id,eq,${prevVersion.Id})`, limit: 1,
});
if (attachments.length === 0) {
return res.status(400).json({ error: 'Upload content to the current version before creating a new one' });
}
} else if (artefact.type === 'copy') {
const texts = await nocodb.list('ArtefactVersionTexts', {
where: `(version_id,eq,${prevVersion.Id})`, limit: 1,
});
if (texts.length === 0) {
return res.status(400).json({ error: 'Add text to the current version before creating a new one' });
}
}
// Can't create new version if artefact hasn't been submitted for review yet
if (artefact.status === 'draft') {
return res.status(400).json({ error: 'Submit the current version for review before creating a new one' });
}
// If revision_requested, user should edit the current version, not create a new one
if (artefact.status === 'revision_requested') {
return res.status(400).json({ error: 'Edit the current version and resubmit instead of creating a new one' });
}
}
const newVersionNumber = versions.length > 0 ? versions[0].version_number + 1 : 1; const newVersionNumber = versions.length > 0 ? versions[0].version_number + 1 : 1;
const created = await nocodb.create('ArtefactVersions', { const created = await nocodb.create('ArtefactVersions', {
@@ -5084,21 +5119,6 @@ app.patch('/api/translations/:id', requireAuth, async (req, res) => {
await nocodb.update('Translations', req.params.id, data); await nocodb.update('Translations', req.params.id, data);
// Auto-update linked post stage (both old and new post if post_id changed)
const oldTransPostId = existing.post_id ? Number(existing.post_id) : null;
const updated = await nocodb.get('Translations', Number(req.params.id));
const newTransPostId = updated?.post_id ? Number(updated.post_id) : null;
const transPostIds = [...new Set([oldTransPostId, newTransPostId].filter(Boolean))];
for (const pid of transPostIds) {
try {
const { getPostComposition, computeStage } = require('./post-composition');
const composition = await getPostComposition(pid);
if (composition) {
await nocodb.update('Posts', pid, { stage: computeStage(composition) });
}
} catch (e) { console.error('Post stage update error:', e); }
}
const record = await nocodb.get('Translations', req.params.id); const record = await nocodb.get('Translations', req.params.id);
const approverIdList = record.approver_ids ? record.approver_ids.split(',').map(s => s.trim()).filter(Boolean) : []; const approverIdList = record.approver_ids ? record.approver_ids.split(',').map(s => s.trim()).filter(Boolean) : [];
const approvers = []; const approvers = [];