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
+50 -33
View File
@@ -24,6 +24,10 @@ const TYPE_ICONS = {
other: Sparkles, other: Sparkles,
} }
const parseApproverIds = (a) =>
a.approvers?.map(u => String(u.id)) ||
(a.approver_ids ? a.approver_ids.split(',').map(s => s.trim()).filter(Boolean) : [])
export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDelete, assignableUsers = [] }) { export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDelete, assignableUsers = [] }) {
const { t } = useLanguage() const { t } = useLanguage()
const { brands } = useContext(AppContext) const { brands } = useContext(AppContext)
@@ -35,14 +39,12 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
const [submitting, setSubmitting] = useState(false) const [submitting, setSubmitting] = useState(false)
const [freshReviewUrl, setFreshReviewUrl] = useState('') const [freshReviewUrl, setFreshReviewUrl] = useState('')
const [copied, setCopied] = useState(false) const [copied, setCopied] = useState(false)
const [activeTab, setActiveTab] = useState('details') const [activeTab, setActiveTab] = useState(artefact.type === 'copy' ? 'versions' : 'details')
// Editable fields // Editable fields — seeded from artefact prop; component is keyed by artefact._id at call site
const [editTitle, setEditTitle] = useState(artefact.title || '') const [editTitle, setEditTitle] = useState(artefact.title || '')
const [editDescription, setEditDescription] = useState(artefact.description || '') const [editDescription, setEditDescription] = useState(artefact.description || '')
const [editApproverIds, setEditApproverIds] = useState( const [editApproverIds, setEditApproverIds] = useState(() => parseApproverIds(artefact))
artefact.approvers?.map(a => String(a.id)) || (artefact.approver_ids ? artefact.approver_ids.split(',').map(s => s.trim()).filter(Boolean) : [])
)
const reviewUrl = freshReviewUrl || (artefact.approval_token ? `${window.location.origin}/review/${artefact.approval_token}` : '') const reviewUrl = freshReviewUrl || (artefact.approval_token ? `${window.location.origin}/review/${artefact.approval_token}` : '')
const [savingDraft, setSavingDraft] = useState(false) const [savingDraft, setSavingDraft] = useState(false)
const [deleting, setDeleting] = useState(false) const [deleting, setDeleting] = useState(false)
@@ -61,14 +63,6 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
loadVersions() loadVersions()
}, [artefact.Id]) }, [artefact.Id])
useEffect(() => {
setEditTitle(artefact.title || '')
setEditDescription(artefact.description || '')
setEditApproverIds(
artefact.approvers?.map(a => String(a.id)) || (artefact.approver_ids ? artefact.approver_ids.split(',').map(s => s.trim()).filter(Boolean) : [])
)
}, [artefact.Id])
const loadVersions = async () => { const loadVersions = async () => {
try { try {
const res = await api.get(`/artefacts/${artefact.Id}/versions`) const res = await api.get(`/artefacts/${artefact.Id}/versions`)
@@ -109,13 +103,6 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
loadVersionData(version.Id) loadVersionData(version.Id)
} }
const handleCreateVersion = async ({ notes, copy_from_previous }) => {
await api.post(`/artefacts/${artefact.Id}/versions`, { notes, copy_from_previous })
toast.success(t('artefacts.versionCreated'))
loadVersions()
onUpdate()
}
const handleAddLanguage = async (languageForm) => { const handleAddLanguage = async (languageForm) => {
await api.post(`/artefacts/${artefact.Id}/versions/${selectedVersion.Id}/texts`, languageForm) await api.post(`/artefacts/${artefact.Id}/versions/${selectedVersion.Id}/texts`, languageForm)
toast.success(t('artefacts.languageAdded')) toast.success(t('artefacts.languageAdded'))
@@ -249,6 +236,12 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
} }
} }
const handleUpdateLanguage = async (textId, content) => {
await api.patch(`/artefact-version-texts/${textId}`, { content })
toast.success(t('artefacts.languageAdded'))
loadVersionData(selectedVersion.Id)
}
const handleDeleteArtefact = async () => { const handleDeleteArtefact = async () => {
setDeleting(true) setDeleting(true)
try { try {
@@ -282,10 +275,10 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
const TypeIcon = TYPE_ICONS[artefact.type] || Sparkles const TypeIcon = TYPE_ICONS[artefact.type] || Sparkles
const tabs = [ const tabs = [
{ key: 'details', label: t('artefacts.details') || 'Details', icon: FileEdit }, { key: 'details', label: t('artefacts.details'), icon: FileEdit },
{ key: 'versions', label: t('artefacts.versions') || 'Versions', icon: Layers, badge: versions.length }, { key: 'versions', label: t('artefacts.versions'), icon: Layers, badge: versions.length },
{ key: 'discussion', label: t('artefacts.comments') || 'Discussion', icon: MessageSquare, badge: comments.length }, { key: 'discussion', label: t('artefacts.comments'), icon: MessageSquare, badge: comments.length },
{ key: 'review', label: t('artefacts.review') || 'Review', icon: ShieldCheck }, { key: 'review', label: t('artefacts.review'), icon: ShieldCheck },
] ]
if (loading) { if (loading) {
@@ -304,7 +297,6 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
onClose={onClose} onClose={onClose}
size="xl" size="xl"
header={ header={
<>
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-lg bg-brand-primary/10 flex items-center justify-center shrink-0"> <div className="w-10 h-10 rounded-lg bg-brand-primary/10 flex items-center justify-center shrink-0">
<TypeIcon className="w-5 h-5 text-brand-primary" /> <TypeIcon className="w-5 h-5 text-brand-primary" />
@@ -329,7 +321,6 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
</div> </div>
</div> </div>
</div> </div>
</>
} }
tabs={tabs} tabs={tabs}
activeTab={activeTab} activeTab={activeTab}
@@ -349,6 +340,7 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
</button> </button>
)} )}
</div> </div>
{activeTab === 'details' && (
<button <button
onClick={handleSaveDraft} onClick={handleSaveDraft}
disabled={savingDraft} disabled={savingDraft}
@@ -358,6 +350,7 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
<Save className="w-3.5 h-3.5" /> <Save className="w-3.5 h-3.5" />
{savingDraft ? t('artefacts.savingDraft') : t('artefacts.saveDraft')} {savingDraft ? t('artefacts.savingDraft') : t('artefacts.saveDraft')}
</button> </button>
)}
</> </>
} }
> >
@@ -376,6 +369,32 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
/> />
</div> </div>
{/* Metadata row */}
<div className="grid grid-cols-2 gap-4 pt-1">
{/* Brand */}
{(artefact.brand_id || artefact.brandId) && (
<div>
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-1">{t('posts.brand')}</h4>
<p className="text-sm text-text-primary">
{brands.find(b => String(b._id) === String(artefact.brand_id || artefact.brandId))?.name || `#${artefact.brand_id || artefact.brandId}`}
</p>
</div>
)}
{/* Created date */}
{artefact.CreatedAt && (
<div>
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-1">{t('common.created')}</h4>
<p className="text-sm text-text-secondary">{new Date(artefact.CreatedAt).toLocaleDateString()}</p>
</div>
)}
{/* Linked post */}
{(artefact.post_id || artefact.postId) && (
<div>
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-1">{t('artefacts.linkedPost')}</h4>
<p className="text-sm text-text-secondary">{t('artefacts.post')} #{artefact.post_id || artefact.postId}</p>
</div>
)}
</div>
</div> </div>
)} )}
@@ -389,8 +408,8 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
uploading={uploading} uploading={uploading}
uploadProgress={uploadProgress} uploadProgress={uploadProgress}
onSelectVersion={handleSelectVersion} onSelectVersion={handleSelectVersion}
onCreateVersion={handleCreateVersion}
onAddLanguage={handleAddLanguage} onAddLanguage={handleAddLanguage}
onUpdateLanguage={handleUpdateLanguage}
onDeleteLanguage={handleDeleteLanguage} onDeleteLanguage={handleDeleteLanguage}
onFileUpload={handleFileUpload} onFileUpload={handleFileUpload}
onDeleteAttachment={handleDeleteAttachment} onDeleteAttachment={handleDeleteAttachment}
@@ -451,7 +470,7 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
</> </>
) : ( ) : (
<div className="text-center py-8 text-sm text-text-tertiary"> <div className="text-center py-8 text-sm text-text-tertiary">
{t('artefacts.selectVersionFirst') || 'Select a version first to view comments.'} {t('artefacts.selectVersionFirst')}
</div> </div>
)} )}
</div> </div>
@@ -530,12 +549,10 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
</div> </div>
)} )}
{/* Empty state when no review actions available */} {/* Empty state: pending_review or unknown status with no review info */}
{!['draft', 'revision_requested', 'rejected'].includes(artefact.status) && !reviewUrl && !artefact.feedback && !(artefact.status === 'approved' && artefact.approved_by_name) && ( {artefact.status === 'pending_review' && !reviewUrl && !artefact.feedback && (
<div className="text-center py-8 text-sm text-text-tertiary"> <div className="text-center py-8 text-sm text-text-tertiary">
{artefact.status === 'pending_review' {t('artefacts.pendingReviewInfo')}
? t('artefacts.pendingReviewInfo') || 'This artefact is currently pending review.'
: t('artefacts.noReviewInfo') || 'No review information available.'}
</div> </div>
)} )}
</div> </div>
@@ -1,5 +1,5 @@
import { useState } from 'react' import { useState } from 'react'
import { Plus, Trash2, Globe, Image as ImageIcon } from 'lucide-react' import { Trash2, Globe, Image as ImageIcon, Pencil } from 'lucide-react'
import PortalSelect from './PortalSelect' import PortalSelect from './PortalSelect'
import UploadZone from './UploadZone' import UploadZone from './UploadZone'
import { useLanguage } from '../i18n/LanguageContext' import { useLanguage } from '../i18n/LanguageContext'
@@ -21,8 +21,8 @@ export function ArtefactDetailVersionsTab({
uploading, uploading,
uploadProgress, uploadProgress,
onSelectVersion, onSelectVersion,
onCreateVersion,
onAddLanguage, onAddLanguage,
onUpdateLanguage,
onDeleteLanguage, onDeleteLanguage,
onFileUpload, onFileUpload,
onDeleteAttachment, onDeleteAttachment,
@@ -31,11 +31,6 @@ export function ArtefactDetailVersionsTab({
}) { }) {
const { t } = useLanguage() const { t } = useLanguage()
const [showNewVersionModal, setShowNewVersionModal] = useState(false)
const [newVersionNotes, setNewVersionNotes] = useState('')
const [copyFromPrevious, setCopyFromPrevious] = useState(false)
const [creatingVersion, setCreatingVersion] = useState(false)
const [showLanguageModal, setShowLanguageModal] = useState(false) const [showLanguageModal, setShowLanguageModal] = useState(false)
const [languageForm, setLanguageForm] = useState({ language_code: '', language_label: '', content: '' }) const [languageForm, setLanguageForm] = useState({ language_code: '', language_label: '', content: '' })
const [savingLanguage, setSavingLanguage] = useState(false) const [savingLanguage, setSavingLanguage] = useState(false)
@@ -43,24 +38,13 @@ export function ArtefactDetailVersionsTab({
const [confirmDeleteLangId, setConfirmDeleteLangId] = useState(null) const [confirmDeleteLangId, setConfirmDeleteLangId] = useState(null)
const [confirmDeleteAttId, setConfirmDeleteAttId] = useState(null) const [confirmDeleteAttId, setConfirmDeleteAttId] = useState(null)
const [editingLangText, setEditingLangText] = useState(null) // { Id, language_code, language_label, content }
const [editLangContent, setEditLangContent] = useState('')
const [savingEditLang, setSavingEditLang] = useState(false)
const [dragOver, setDragOver] = useState(false) const [dragOver, setDragOver] = useState(false)
const [driveUrl, setDriveUrl] = useState('') const [driveUrl, setDriveUrl] = useState('')
const handleCreateVersion = async () => {
setCreatingVersion(true)
try {
await onCreateVersion({
notes: newVersionNotes || `Version ${(versions[versions.length - 1]?.version_number || 0) + 1}`,
copy_from_previous: artefact.type === 'copy' ? copyFromPrevious : false,
})
setShowNewVersionModal(false)
setNewVersionNotes('')
setCopyFromPrevious(false)
} finally {
setCreatingVersion(false)
}
}
const handleAddLanguage = async () => { const handleAddLanguage = async () => {
if (!languageForm.language_code || !languageForm.language_label || !languageForm.content) return if (!languageForm.language_code || !languageForm.language_label || !languageForm.content) return
setSavingLanguage(true) setSavingLanguage(true)
@@ -83,6 +67,17 @@ export function ArtefactDetailVersionsTab({
setConfirmDeleteAttId(null) setConfirmDeleteAttId(null)
} }
const handleSaveEditLang = async () => {
if (!editingLangText || !editLangContent.trim()) return
setSavingEditLang(true)
try {
await onUpdateLanguage(editingLangText.Id, editLangContent)
setEditingLangText(null)
} finally {
setSavingEditLang(false)
}
}
const handleVideoDrop = (e) => { const handleVideoDrop = (e) => {
e.preventDefault() e.preventDefault()
setDragOver(false) setDragOver(false)
@@ -101,30 +96,10 @@ export function ArtefactDetailVersionsTab({
return ( return (
<> <>
<div className="p-6 space-y-5"> <div className="p-6 space-y-5">
{/* Version Timeline */} {/* Version Timeline — only shown when there are multiple rounds */}
{versions.length > 1 && (
<div> <div>
<div className="flex items-center justify-between mb-3"> <h4 className="text-xs font-semibold text-text-tertiary uppercase mb-3">{t('artefacts.versions')}</h4>
<h4 className="text-xs font-semibold text-text-tertiary uppercase">{t('artefacts.versions')}</h4>
{artefact.status === 'rejected' && (
<button
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"
>
<Plus className="w-3 h-3" />
{t('artefacts.newVersion')}
</button>
)}
</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}
@@ -132,6 +107,7 @@ export function ArtefactDetailVersionsTab({
artefactType={artefact.type} artefactType={artefact.type}
/> />
</div> </div>
)}
{/* Type-specific content */} {/* Type-specific content */}
{versionData && selectedVersion && ( {versionData && selectedVersion && (
@@ -145,7 +121,7 @@ export function ArtefactDetailVersionsTab({
onClick={() => setShowLanguageModal(true)} onClick={() => setShowLanguageModal(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"
> >
<Plus className="w-3 h-3" /> <Globe className="w-3 h-3" />
{t('artefacts.addLanguage')} {t('artefacts.addLanguage')}
</button> </button>
</div> </div>
@@ -161,13 +137,22 @@ export function ArtefactDetailVersionsTab({
</span> </span>
<span className="text-sm font-medium text-text-primary">{text.language_label}</span> <span className="text-sm font-medium text-text-primary">{text.language_label}</span>
</div> </div>
<div className="flex items-center gap-1">
<button
onClick={() => { setEditingLangText(text); setEditLangContent(text.content || '') }}
className="p-1 text-text-tertiary hover:text-brand-primary rounded transition-colors"
title={t('common.edit')}
>
<Pencil className="w-3.5 h-3.5" />
</button>
<button <button
onClick={() => setConfirmDeleteLangId(text.Id)} onClick={() => setConfirmDeleteLangId(text.Id)}
className="text-red-600 hover:text-red-700" className="p-1 text-red-500 hover:text-red-700 rounded transition-colors"
> >
<Trash2 className="w-4 h-4" /> <Trash2 className="w-3.5 h-3.5" />
</button> </button>
</div> </div>
</div>
<div className="bg-surface rounded border border-border p-3 text-sm text-text-primary whitespace-pre-wrap font-sans"> <div className="bg-surface rounded border border-border p-3 text-sm text-text-primary whitespace-pre-wrap font-sans">
{text.content} {text.content}
</div> </div>
@@ -345,43 +330,39 @@ export function ArtefactDetailVersionsTab({
</div> </div>
</Modal> </Modal>
{/* New Version Modal */} {/* Edit Language Modal */}
<Modal isOpen={showNewVersionModal} onClose={() => setShowNewVersionModal(false)} title={t('artefacts.createNewVersion')} size="sm"> <Modal isOpen={!!editingLangText} onClose={() => setEditingLangText(null)} title={t('artefacts.editLanguage')} size="md">
<div className="space-y-4"> <div className="space-y-4">
{editingLangText && (
<div className="flex items-center gap-2">
<span className="px-2 py-0.5 bg-surface border border-border rounded text-xs font-mono font-medium">
{editingLangText.language_code}
</span>
<span className="text-sm font-medium text-text-primary">{editingLangText.language_label}</span>
</div>
)}
<div> <div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('artefacts.versionNotes')}</label> <label className="block text-sm font-medium text-text-primary mb-1">{t('artefacts.contentLabel')}</label>
<textarea <textarea
value={newVersionNotes} value={editLangContent}
onChange={e => setNewVersionNotes(e.target.value)} onChange={e => setEditLangContent(e.target.value)}
rows={3} rows={8}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20" className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 font-sans"
placeholder={t('artefacts.whatChanged')}
/> />
</div> </div>
{artefact.type === 'copy' && versions.length > 0 && (
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={copyFromPrevious}
onChange={e => setCopyFromPrevious(e.target.checked)}
className="w-4 h-4 text-brand-primary border-border rounded focus:ring-brand-primary"
/>
<span className="text-sm text-text-secondary">{t('artefacts.copyLanguages')}</span>
</label>
)}
<div className="flex items-center justify-end gap-3 pt-4 border-t border-border"> <div className="flex items-center justify-end gap-3 pt-4 border-t border-border">
<button <button
onClick={() => setShowNewVersionModal(false)} onClick={() => setEditingLangText(null)}
className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg" className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg"
> >
{t('common.cancel')} {t('common.cancel')}
</button> </button>
<button <button
onClick={handleCreateVersion} onClick={handleSaveEditLang}
disabled={creatingVersion} disabled={savingEditLang}
className="px-5 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 shadow-sm" className="px-5 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 shadow-sm"
> >
{creatingVersion ? t('artefacts.creatingVersion') : t('artefacts.createVersion')} {savingEditLang ? t('header.saving') : t('common.save')}
</button> </button>
</div> </div>
</div> </div>
+4
View File
@@ -31,6 +31,7 @@
"common.loading": "جاري التحميل...", "common.loading": "جاري التحميل...",
"common.unassigned": "غير مُسند", "common.unassigned": "غير مُسند",
"common.close": "إغلاق", "common.close": "إغلاق",
"common.created": "تاريخ الإنشاء",
"common.required": "مطلوب", "common.required": "مطلوب",
"common.saveFailed": "فشل الحفظ. حاول مجدداً.", "common.saveFailed": "فشل الحفظ. حاول مجدداً.",
"common.updateFailed": "فشل التحديث. حاول مجدداً.", "common.updateFailed": "فشل التحديث. حاول مجدداً.",
@@ -761,6 +762,9 @@
"issues.trackingLinkCopied": "تم نسخ رابط التتبع!", "issues.trackingLinkCopied": "تم نسخ رابط التتبع!",
"issues.deleteAttachment": "حذف المرفق؟", "issues.deleteAttachment": "حذف المرفق؟",
"issues.deleteAttachmentDesc": "لا يمكن التراجع عن هذا الإجراء.", "issues.deleteAttachmentDesc": "لا يمكن التراجع عن هذا الإجراء.",
"artefacts.editLanguage": "تعديل اللغة",
"artefacts.linkedPost": "المنشور المرتبط",
"artefacts.post": "منشور",
"artefacts.deleteLanguage": "حذف هذه اللغة؟", "artefacts.deleteLanguage": "حذف هذه اللغة؟",
"artefacts.deleteLanguageDesc": "سيتم إزالة المحتوى لهذه اللغة.", "artefacts.deleteLanguageDesc": "سيتم إزالة المحتوى لهذه اللغة.",
"artefacts.deleteAttachment": "حذف هذا المرفق؟", "artefacts.deleteAttachment": "حذف هذا المرفق؟",
+4
View File
@@ -31,6 +31,7 @@
"common.loading": "Loading...", "common.loading": "Loading...",
"common.unassigned": "Unassigned", "common.unassigned": "Unassigned",
"common.close": "Close", "common.close": "Close",
"common.created": "Created",
"common.required": "Required", "common.required": "Required",
"common.saveFailed": "Failed to save. Please try again.", "common.saveFailed": "Failed to save. Please try again.",
"common.updateFailed": "Failed to update. Please try again.", "common.updateFailed": "Failed to update. Please try again.",
@@ -761,6 +762,9 @@
"issues.trackingLinkCopied": "Tracking link copied to clipboard!", "issues.trackingLinkCopied": "Tracking link copied to clipboard!",
"issues.deleteAttachment": "Delete attachment?", "issues.deleteAttachment": "Delete attachment?",
"issues.deleteAttachmentDesc": "This action cannot be undone.", "issues.deleteAttachmentDesc": "This action cannot be undone.",
"artefacts.editLanguage": "Edit Language",
"artefacts.linkedPost": "Linked Post",
"artefacts.post": "Post",
"artefacts.deleteLanguage": "Delete this language?", "artefacts.deleteLanguage": "Delete this language?",
"artefacts.deleteLanguageDesc": "The content for this language will be removed.", "artefacts.deleteLanguageDesc": "The content for this language will be removed.",
"artefacts.deleteAttachment": "Delete this attachment?", "artefacts.deleteAttachment": "Delete this attachment?",
+100 -104
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 { 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 { ArrowLeft, Save, FileText, Image as ImageIcon, Film, Type, Search, Link2, Unlink, Plus, CheckCircle, Clock, X, ExternalLink } from 'lucide-react'
import { AppContext } from '../App' import { AppContext } from '../App'
@@ -14,6 +14,20 @@ import { useToast } from '../components/ToastContainer'
const STATUS_OPTS = ['draft', 'in_review', 'approved', 'rejected', 'scheduled', 'published'] 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() { export default function PostDetail() {
const { id } = useParams() const { id } = useParams()
const navigate = useNavigate() const navigate = useNavigate()
@@ -37,21 +51,18 @@ export default function PostDetail() {
const [platforms, setPlatforms] = useState([]) const [platforms, setPlatforms] = useState([])
const [scheduledDate, setScheduledDate] = 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 [activePicker, setActivePicker] = useState(null) // 'caption' | 'body' | 'design' | 'video'
const [pickerSearch, setPickerSearch] = useState('') const [pickerSearch, setPickerSearch] = useState('')
const [linkCandidates, setLinkCandidates] = useState([]) const [linkCandidates, setLinkCandidates] = useState([])
const [linking, setLinking] = useState(false) const [linking, setLinking] = useState(false)
const allArtefactsRef = useRef(null)
// Sub-panels // Sub-panels
const [openArtefact, setOpenArtefact] = useState(null) const [openArtefact, setOpenArtefact] = useState(null)
useEffect(() => { const loadPost = useCallback(async () => {
loadPost()
api.get('/campaigns').then(r => setCampaigns(Array.isArray(r) ? r : [])).catch(() => {})
}, [id])
const loadPost = async () => {
try { try {
const [p, comp] = await Promise.all([ const [p, comp] = await Promise.all([
api.get(`/posts/${id}`), api.get(`/posts/${id}`),
@@ -73,7 +84,12 @@ export default function PostDetail() {
} finally { } finally {
setLoading(false) setLoading(false)
} }
} }, [id])
useEffect(() => {
loadPost()
api.get('/campaigns').then(r => setCampaigns(Array.isArray(r) ? r : [])).catch(() => {})
}, [loadPost])
const loadComposition = useCallback(async () => { const loadComposition = useCallback(async () => {
try { try {
@@ -96,7 +112,8 @@ export default function PostDetail() {
scheduled_date: scheduledDate || null, scheduled_date: scheduledDate || null,
}) })
toast.success(t('posts.updated')) 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 { } catch {
toast.error(t('common.saveFailed')) toast.error(t('common.saveFailed'))
} finally { } finally {
@@ -110,20 +127,22 @@ export default function PostDetail() {
// ─── Link / Unlink / Create ─── // ─── 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) => { const openLinkPicker = async (type) => {
setActivePicker(type) setActivePicker(type)
setPickerSearch('') setPickerSearch('')
try { try {
const all = await api.get('/artefacts') if (!allArtefactsRef.current) allArtefactsRef.current = await api.get('/artefacts')
let typeFilter const all = Array.isArray(allArtefactsRef.current) ? allArtefactsRef.current : []
if (type === 'caption') typeFilter = a => a.type === 'copy' && a.copy_type === 'caption' setLinkCandidates(all.filter(a => {
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 => {
const linkedTo = a.post_id || a.postId 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 { } catch {
setLinkCandidates([]) setLinkCandidates([])
@@ -135,6 +154,7 @@ export default function PostDetail() {
setLinking(true) setLinking(true)
try { try {
await api.patch(`/artefacts/${itemId}`, { post_id: Number(id) }) await api.patch(`/artefacts/${itemId}`, { post_id: Number(id) })
allArtefactsRef.current = null
toast.success(t('posts.updated')) toast.success(t('posts.updated'))
setActivePicker(null) setActivePicker(null)
loadComposition() loadComposition()
@@ -146,13 +166,11 @@ export default function PostDetail() {
} }
const handleUnlink = async (type) => { const handleUnlink = async (type) => {
const piece = type === 'caption' ? composition?.caption const piece = composition?.[PIECE_MAP[type]]
: type === 'body' ? composition?.body_copy
: type === 'design' ? composition?.design
: composition?.video
if (!piece) return if (!piece) return
try { try {
await api.patch(`/artefacts/${piece.id}`, { post_id: null }) await api.patch(`/artefacts/${piece.id}`, { post_id: null })
allArtefactsRef.current = null
toast.success(t('posts.updated')) toast.success(t('posts.updated'))
loadComposition() loadComposition()
} catch { } catch {
@@ -161,10 +179,7 @@ export default function PostDetail() {
} }
const handleOpenPiece = async (type) => { const handleOpenPiece = async (type) => {
const piece = type === 'caption' ? composition?.caption const piece = composition?.[PIECE_MAP[type]]
: type === 'body' ? composition?.body_copy
: type === 'design' ? composition?.design
: composition?.video
if (!piece) return if (!piece) return
try { try {
const full = await api.get(`/artefacts/${piece.id}`) const full = await api.get(`/artefacts/${piece.id}`)
@@ -173,21 +188,22 @@ export default function PostDetail() {
} }
const handleCreate = async (type) => { const handleCreate = async (type) => {
const label = type === 'caption' ? t('postDetail.captionCopy') if (creating) return
: type === 'body' ? t('postDetail.bodyCopy') setCreating(true)
: type === 'design' ? t('postDetail.design')
: t('postDetail.video')
try { try {
const created = await api.post('/artefacts', { 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, type: type === 'caption' || type === 'body' ? 'copy' : type,
copy_type: type === 'caption' ? 'caption' : type === 'body' ? 'body' : undefined, copy_type: type === 'caption' ? 'caption' : type === 'body' ? 'body' : undefined,
post_id: Number(id), post_id: Number(id),
}) })
allArtefactsRef.current = null
setOpenArtefact(created) setOpenArtefact(created)
loadComposition() loadComposition()
} catch (err) { } catch {
toast.error(t('common.saveFailed')) toast.error(t('common.saveFailed'))
} finally {
setCreating(false)
} }
} }
@@ -224,6 +240,16 @@ export default function PostDetail() {
return (c.title || '').toLowerCase().includes(pickerSearch.toLowerCase()) 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 waitingOn = composition?.waiting_on || []
const piecesReady = composition?.pieces_ready || false const piecesReady = composition?.pieces_ready || false
const hasPieces = composition?.caption || composition?.body_copy || composition?.design || composition?.video const hasPieces = composition?.caption || composition?.body_copy || composition?.design || composition?.video
@@ -316,79 +342,32 @@ export default function PostDetail() {
<button <button
onClick={handleSave} onClick={handleSave}
disabled={saving} 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" /> <Save className="w-4 h-4" />
{saving ? t('common.loading') : t('common.save')} {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> </button>
</div> </div>
</div> </div>
{/* ─── ASSET CARDS ─── */} {/* ─── ASSET CARDS ─── */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{ASSET_TYPES.map(type => (
<AssetCard <AssetCard
type="caption" key={type}
label={t('postDetail.captionCopy')} id={`asset-${type}`}
icon={Type} type={type}
piece={composition?.caption} label={t(LABEL_KEYS[type])}
onCreate={() => handleCreate('caption')} icon={ASSET_ICONS[type]}
onOpen={() => handleOpenPiece('caption')} piece={composition?.[PIECE_MAP[type]]}
onUnlink={() => handleUnlink('caption')} onCreate={() => handleCreate(type)}
onOpenPicker={() => openLinkPicker('caption')} creating={creating}
activePicker={activePicker} onOpen={() => handleOpenPiece(type)}
pickerSearch={pickerSearch} onUnlink={() => handleUnlink(type)}
filteredCandidates={filteredCandidates} onOpenPicker={() => openLinkPicker(type)}
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} activePicker={activePicker}
pickerSearch={pickerSearch} pickerSearch={pickerSearch}
filteredCandidates={filteredCandidates} filteredCandidates={filteredCandidates}
@@ -398,6 +377,7 @@ export default function PostDetail() {
onClosePicker={() => setActivePicker(null)} onClosePicker={() => setActivePicker(null)}
t={t} t={t}
/> />
))}
</div> </div>
{/* ─── READINESS ─── */} {/* ─── READINESS ─── */}
@@ -411,9 +391,23 @@ export default function PostDetail() {
<span className="text-sm font-medium">{t('postDetail.allPiecesApproved')}</span> <span className="text-sm font-medium">{t('postDetail.allPiecesApproved')}</span>
</div> </div>
) : ( ) : (
<div className="flex items-center gap-2 text-amber-600"> <div className="flex items-start gap-2 text-amber-600">
<Clock className="w-5 h-5" /> <Clock className="w-5 h-5 shrink-0 mt-0.5" />
<span className="text-sm font-medium">{t('postDetail.waitingOn')}: {waitingOn.join(', ')}</span> <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>
)} )}
</div> </div>
@@ -427,6 +421,7 @@ export default function PostDetail() {
{/* ─── SUB-PANELS (they render their own SlidePanel internally) ─── */} {/* ─── SUB-PANELS (they render their own SlidePanel internally) ─── */}
{openArtefact && ( {openArtefact && (
<ArtefactDetailPanel <ArtefactDetailPanel
key={openArtefact._id}
artefact={openArtefact} artefact={openArtefact}
onClose={() => { setOpenArtefact(null); loadComposition() }} onClose={() => { setOpenArtefact(null); loadComposition() }}
onUpdate={loadComposition} onUpdate={loadComposition}
@@ -441,8 +436,8 @@ export default function PostDetail() {
// ─── Asset Card Component ─── // ─── Asset Card Component ───
function AssetCard({ function AssetCard({
type, label, icon: Icon, piece, id, type, label, icon: Icon, piece,
onCreate, onOpen, onUnlink, onCreate, creating, onOpen, onUnlink,
onOpenPicker, activePicker, pickerSearch, filteredCandidates, linking, onOpenPicker, activePicker, pickerSearch, filteredCandidates, linking,
onLink, onPickerSearchChange, onClosePicker, t, onLink, onPickerSearchChange, onClosePicker, t,
}) { }) {
@@ -453,7 +448,7 @@ function AssetCard({
const isApproved = piece?.status === 'approved' const isApproved = piece?.status === 'approved'
return ( 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 */} {/* Header */}
<div className="flex items-center gap-2 mb-3"> <div className="flex items-center gap-2 mb-3">
<Icon className="w-4 h-4 text-text-tertiary" /> <Icon className="w-4 h-4 text-text-tertiary" />
@@ -561,10 +556,11 @@ function AssetCard({
</button> </button>
<button <button
onClick={onCreate} 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" /> <Plus className="w-3.5 h-3.5" />
{t('postDetail.createNew')} {creating ? t('common.loading') : t('postDetail.createNew')}
</button> </button>
</div> </div>
)} )}
+15
View File
@@ -40,17 +40,25 @@ function buildWhere(conditions) {
.join('~and'); .join('~and');
} }
const REQUEST_TIMEOUT_MS = 20_000;
async function request(method, url, body) { async function request(method, url, body) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
const opts = { const opts = {
method, method,
headers: { headers: {
'xc-token': NOCODB_TOKEN, 'xc-token': NOCODB_TOKEN,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
signal: controller.signal,
}; };
if (body !== undefined) opts.body = JSON.stringify(body); if (body !== undefined) opts.body = JSON.stringify(body);
try {
const res = await fetch(url, opts); const res = await fetch(url, opts);
clearTimeout(timer);
if (!res.ok) { if (!res.ok) {
let details; let details;
try { details = await res.json(); } catch {} try { details = await res.json(); } catch {}
@@ -63,6 +71,13 @@ async function request(method, url, body) {
// DELETE returns empty or {msg} // DELETE returns empty or {msg}
const text = await res.text(); const text = await res.text();
return text ? JSON.parse(text) : {}; return text ? JSON.parse(text) : {};
} catch (err) {
clearTimeout(timer);
if (err.name === 'AbortError') {
throw new NocoDBError(`NocoDB ${method} ${url} timed out after ${REQUEST_TIMEOUT_MS}ms`, 408);
}
throw err;
}
} }
// ─── Link Resolution ───────────────────────────────────────── // ─── Link Resolution ─────────────────────────────────────────
+103 -5
View File
@@ -591,12 +591,17 @@ async function ensureFKColumns() {
for (const col of columns) { for (const col of columns) {
if (!existingCols.has(col)) { if (!existingCols.has(col)) {
console.log(` Adding column ${table}.${col}...`); console.log(` Adding FK column ${table}.${col}...`);
await fetch(`${nocodb.url}/api/v2/meta/tables/${tableId}/columns`, { const colRes = await fetch(`${nocodb.url}/api/v2/meta/tables/${tableId}/columns`, {
method: 'POST', method: 'POST',
headers: { 'xc-token': nocodb.token, 'Content-Type': 'application/json' }, headers: { 'xc-token': nocodb.token, 'Content-Type': 'application/json' },
body: JSON.stringify({ title: col, uidt: 'Number' }), body: JSON.stringify({ title: col, uidt: 'Number' }),
}); });
if (colRes.ok) {
console.log(` ✓ Created ${table}.${col}`);
} else {
console.warn(` ⚠ Failed to create ${table}.${col}: ${colRes.status}`);
}
} }
} }
} catch (err) { } catch (err) {
@@ -668,6 +673,28 @@ app.get('/api/health', async (req, res) => {
}); });
}); });
// ─── EMAIL TEST ─────────────────────────────────────────────────
app.post('/api/admin/test-email', requireAuth, async (req, res) => {
if (req.session.userRole !== 'superadmin') return res.status(403).json({ error: 'Superadmin only' });
const to = req.body.to || req.session.userEmail;
if (!to) return res.status(400).json({ error: 'No recipient — pass { "to": "email@example.com" }' });
const { sendMail, getSmtpConfig } = require('./mail');
const config = getSmtpConfig();
if (!config) return res.status(503).json({ error: 'SMTP not configured', env: { server: !!process.env.CLOUDRON_MAIL_SMTP_SERVER || !!process.env.MAIL_SMTP_SERVER } });
try {
const info = await sendMail({
to,
subject: 'Rawaj — Test Email',
html: '<p>If you received this, email delivery is working correctly.</p>',
text: 'If you received this, email delivery is working correctly.',
});
res.json({ success: true, to, messageId: info?.messageId, smtp: { host: config.host, port: config.port, from: config.from } });
} catch (err) {
res.status(500).json({ success: false, error: err.message, code: err.code, smtp: { host: config.host, port: config.port } });
}
});
// ─── SETUP ROUTES ─────────────────────────────────────────────── // ─── SETUP ROUTES ───────────────────────────────────────────────
app.get('/api/setup/status', async (req, res) => { app.get('/api/setup/status', async (req, res) => {
@@ -4007,7 +4034,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, copy_type } = req.body; const { title, description, type, brand_id, content, project_id, campaign_id, approver_ids, copy_type, post_id } = 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 {
@@ -4018,6 +4045,7 @@ app.post('/api/artefacts', requireAuth, async (req, res) => {
status: 'draft', status: 'draft',
content: content || null, content: content || null,
brand_id: brand_id ? Number(brand_id) : null, brand_id: brand_id ? Number(brand_id) : null,
post_id: post_id ? Number(post_id) : null,
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,
@@ -4025,7 +4053,7 @@ app.post('/api/artefacts', requireAuth, async (req, res) => {
created_by_user_id: req.session.userId, created_by_user_id: req.session.userId,
current_version: 1, current_version: 1,
}; };
console.log('[POST /artefacts] Creating with:', JSON.stringify({ approver_ids: createData.approver_ids, project_id: createData.project_id, campaign_id: createData.campaign_id })); console.log('[POST /artefacts] Creating with:', JSON.stringify({ post_id: createData.post_id, type: createData.type, copy_type: createData.copy_type, approver_ids: createData.approver_ids }));
const created = await nocodb.create('Artefacts', createData); const created = await nocodb.create('Artefacts', createData);
console.log('[POST /artefacts] NocoDB returned:', JSON.stringify({ Id: created.Id, approver_ids: created.approver_ids })); console.log('[POST /artefacts] NocoDB returned:', JSON.stringify({ Id: created.Id, approver_ids: created.approver_ids }));
@@ -4039,7 +4067,7 @@ app.post('/api/artefacts', requireAuth, async (req, res) => {
}); });
const artefact = await nocodb.get('Artefacts', created.Id); const artefact = await nocodb.get('Artefacts', created.Id);
console.log('[POST /artefacts] After re-read:', JSON.stringify({ Id: artefact.Id, approver_ids: artefact.approver_ids })); console.log('[POST /artefacts] After re-read:', JSON.stringify({ Id: artefact.Id, post_id: artefact.post_id, copy_type: artefact.copy_type, approver_ids: artefact.approver_ids }));
const approverIdList = artefact.approver_ids ? artefact.approver_ids.split(',').map(s => s.trim()).filter(Boolean) : []; const approverIdList = artefact.approver_ids ? artefact.approver_ids.split(',').map(s => s.trim()).filter(Boolean) : [];
const approvers = []; const approvers = [];
for (const id of approverIdList) { for (const id of approverIdList) {
@@ -4267,6 +4295,45 @@ app.post('/api/artefacts/:id/link-post', requireAuth, async (req, res) => {
// ─── ARTEFACT VERSIONS ────────────────────────────────────────── // ─── ARTEFACT VERSIONS ──────────────────────────────────────────
// Creates the next working version automatically after rejection/revision.
// For copy artefacts, texts are copied from the last version so the creator
// doesn't start with a blank slate.
async function autoAdvanceArtefactVersion(artefact, nocodb, QUERY_LIMITS) {
const versions = await nocodb.list('ArtefactVersions', {
where: `(artefact_id,eq,${artefact.Id})`,
sort: '-version_number',
limit: 1,
});
const latest = versions[0];
if (!latest) return;
const nextNumber = latest.version_number + 1;
const created = await nocodb.create('ArtefactVersions', {
artefact_id: artefact.Id,
version_number: nextNumber,
created_at: new Date().toISOString(),
notes: `Round ${nextNumber}`,
});
if (artefact.type === 'copy') {
const prevTexts = await nocodb.list('ArtefactVersionTexts', {
where: `(version_id,eq,${latest.Id})`,
limit: QUERY_LIMITS.large,
});
for (const text of prevTexts) {
await nocodb.create('ArtefactVersionTexts', {
version_id: created.Id,
language_code: text.language_code,
language_label: text.language_label,
content: text.content,
});
}
}
await nocodb.update('Artefacts', artefact.Id, { current_version: nextNumber });
}
// List all versions for an artefact // List all versions for an artefact
app.get('/api/artefacts/:id/versions', requireAuth, async (req, res) => { app.get('/api/artefacts/:id/versions', requireAuth, async (req, res) => {
try { try {
@@ -4466,6 +4533,31 @@ app.post('/api/artefacts/:id/versions/:versionId/texts', requireAuth, async (req
} }
}); });
// Update language entry content
app.patch('/api/artefact-version-texts/:id', requireAuth, async (req, res) => {
try {
const text = await nocodb.get('ArtefactVersionTexts', req.params.id);
if (!text) return res.status(404).json({ error: 'Text not found' });
const version = await nocodb.get('ArtefactVersions', text.version_id);
const artefact = await nocodb.get('Artefacts', version.artefact_id);
if (req.session.userRole === 'contributor' && artefact.created_by_user_id !== req.session.userId) {
return res.status(403).json({ error: 'You can only manage texts for your own artefacts' });
}
const { content } = req.body;
if (content === undefined) return res.status(400).json({ error: 'content is required' });
await nocodb.update('ArtefactVersionTexts', req.params.id, { content });
const updated = await nocodb.get('ArtefactVersionTexts', req.params.id);
res.json(updated);
} catch (err) {
console.error('Update text error:', err);
res.status(500).json({ error: 'Failed to update text' });
}
});
// Delete language entry // Delete language entry
app.delete('/api/artefact-version-texts/:id', requireAuth, async (req, res) => { app.delete('/api/artefact-version-texts/:id', requireAuth, async (req, res) => {
try { try {
@@ -4823,6 +4915,9 @@ app.post('/api/public/review/:token/reject', async (req, res) => {
feedback: feedback || '', feedback: feedback || '',
}); });
// Auto-advance to next working version
await autoAdvanceArtefactVersion(artefact, nocodb, QUERY_LIMITS);
res.json({ success: true, message: 'Artefact rejected' }); res.json({ success: true, message: 'Artefact rejected' });
notify.notifyRejected({ type: 'artefact', record: artefact, approverName: approved_by_name, feedback }); notify.notifyRejected({ type: 'artefact', record: artefact, approverName: approved_by_name, feedback });
} catch (err) { } catch (err) {
@@ -4853,6 +4948,9 @@ app.post('/api/public/review/:token/revision', async (req, res) => {
feedback: feedback || '', feedback: feedback || '',
}); });
// Auto-advance to next working version
await autoAdvanceArtefactVersion(artefact, nocodb, QUERY_LIMITS);
res.json({ success: true, message: 'Revision requested' }); res.json({ success: true, message: 'Revision requested' });
notify.notifyRevisionRequested({ record: artefact, approverName: approved_by_name, feedback }); notify.notifyRevisionRequested({ record: artefact, approverName: approved_by_name, feedback });
} catch (err) { } catch (err) {