feat: artefact version auto-advance, post_id on artefacts, test-email endpoint
Deploy / deploy (push) Successful in 13s
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:
@@ -24,6 +24,10 @@ const TYPE_ICONS = {
|
||||
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 = [] }) {
|
||||
const { t } = useLanguage()
|
||||
const { brands } = useContext(AppContext)
|
||||
@@ -35,14 +39,12 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [freshReviewUrl, setFreshReviewUrl] = useState('')
|
||||
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 [editDescription, setEditDescription] = useState(artefact.description || '')
|
||||
const [editApproverIds, setEditApproverIds] = useState(
|
||||
artefact.approvers?.map(a => String(a.id)) || (artefact.approver_ids ? artefact.approver_ids.split(',').map(s => s.trim()).filter(Boolean) : [])
|
||||
)
|
||||
const [editApproverIds, setEditApproverIds] = useState(() => parseApproverIds(artefact))
|
||||
const reviewUrl = freshReviewUrl || (artefact.approval_token ? `${window.location.origin}/review/${artefact.approval_token}` : '')
|
||||
const [savingDraft, setSavingDraft] = useState(false)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
@@ -61,14 +63,6 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
|
||||
loadVersions()
|
||||
}, [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 () => {
|
||||
try {
|
||||
const res = await api.get(`/artefacts/${artefact.Id}/versions`)
|
||||
@@ -109,13 +103,6 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
|
||||
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) => {
|
||||
await api.post(`/artefacts/${artefact.Id}/versions/${selectedVersion.Id}/texts`, languageForm)
|
||||
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 () => {
|
||||
setDeleting(true)
|
||||
try {
|
||||
@@ -282,10 +275,10 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
|
||||
const TypeIcon = TYPE_ICONS[artefact.type] || Sparkles
|
||||
|
||||
const tabs = [
|
||||
{ key: 'details', label: t('artefacts.details') || 'Details', icon: FileEdit },
|
||||
{ key: 'versions', label: t('artefacts.versions') || 'Versions', icon: Layers, badge: versions.length },
|
||||
{ key: 'discussion', label: t('artefacts.comments') || 'Discussion', icon: MessageSquare, badge: comments.length },
|
||||
{ key: 'review', label: t('artefacts.review') || 'Review', icon: ShieldCheck },
|
||||
{ key: 'details', label: t('artefacts.details'), icon: FileEdit },
|
||||
{ key: 'versions', label: t('artefacts.versions'), icon: Layers, badge: versions.length },
|
||||
{ key: 'discussion', label: t('artefacts.comments'), icon: MessageSquare, badge: comments.length },
|
||||
{ key: 'review', label: t('artefacts.review'), icon: ShieldCheck },
|
||||
]
|
||||
|
||||
if (loading) {
|
||||
@@ -304,7 +297,6 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
|
||||
onClose={onClose}
|
||||
size="xl"
|
||||
header={
|
||||
<>
|
||||
<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">
|
||||
<TypeIcon className="w-5 h-5 text-brand-primary" />
|
||||
@@ -329,7 +321,6 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
tabs={tabs}
|
||||
activeTab={activeTab}
|
||||
@@ -349,6 +340,7 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{activeTab === 'details' && (
|
||||
<button
|
||||
onClick={handleSaveDraft}
|
||||
disabled={savingDraft}
|
||||
@@ -358,6 +350,7 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
|
||||
<Save className="w-3.5 h-3.5" />
|
||||
{savingDraft ? t('artefacts.savingDraft') : t('artefacts.saveDraft')}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
@@ -376,6 +369,32 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
|
||||
/>
|
||||
</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>
|
||||
)}
|
||||
|
||||
@@ -389,8 +408,8 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
|
||||
uploading={uploading}
|
||||
uploadProgress={uploadProgress}
|
||||
onSelectVersion={handleSelectVersion}
|
||||
onCreateVersion={handleCreateVersion}
|
||||
onAddLanguage={handleAddLanguage}
|
||||
onUpdateLanguage={handleUpdateLanguage}
|
||||
onDeleteLanguage={handleDeleteLanguage}
|
||||
onFileUpload={handleFileUpload}
|
||||
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">
|
||||
{t('artefacts.selectVersionFirst') || 'Select a version first to view comments.'}
|
||||
{t('artefacts.selectVersionFirst')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -530,12 +549,10 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state when no review actions available */}
|
||||
{!['draft', 'revision_requested', 'rejected'].includes(artefact.status) && !reviewUrl && !artefact.feedback && !(artefact.status === 'approved' && artefact.approved_by_name) && (
|
||||
{/* Empty state: pending_review or unknown status with no review info */}
|
||||
{artefact.status === 'pending_review' && !reviewUrl && !artefact.feedback && (
|
||||
<div className="text-center py-8 text-sm text-text-tertiary">
|
||||
{artefact.status === 'pending_review'
|
||||
? t('artefacts.pendingReviewInfo') || 'This artefact is currently pending review.'
|
||||
: t('artefacts.noReviewInfo') || 'No review information available.'}
|
||||
{t('artefacts.pendingReviewInfo')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 UploadZone from './UploadZone'
|
||||
import { useLanguage } from '../i18n/LanguageContext'
|
||||
@@ -21,8 +21,8 @@ export function ArtefactDetailVersionsTab({
|
||||
uploading,
|
||||
uploadProgress,
|
||||
onSelectVersion,
|
||||
onCreateVersion,
|
||||
onAddLanguage,
|
||||
onUpdateLanguage,
|
||||
onDeleteLanguage,
|
||||
onFileUpload,
|
||||
onDeleteAttachment,
|
||||
@@ -31,11 +31,6 @@ export function ArtefactDetailVersionsTab({
|
||||
}) {
|
||||
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 [languageForm, setLanguageForm] = useState({ language_code: '', language_label: '', content: '' })
|
||||
const [savingLanguage, setSavingLanguage] = useState(false)
|
||||
@@ -43,24 +38,13 @@ export function ArtefactDetailVersionsTab({
|
||||
const [confirmDeleteLangId, setConfirmDeleteLangId] = 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 [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 () => {
|
||||
if (!languageForm.language_code || !languageForm.language_label || !languageForm.content) return
|
||||
setSavingLanguage(true)
|
||||
@@ -83,6 +67,17 @@ export function ArtefactDetailVersionsTab({
|
||||
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) => {
|
||||
e.preventDefault()
|
||||
setDragOver(false)
|
||||
@@ -101,30 +96,10 @@ export function ArtefactDetailVersionsTab({
|
||||
return (
|
||||
<>
|
||||
<div className="p-6 space-y-5">
|
||||
{/* Version Timeline */}
|
||||
{/* Version Timeline — only shown when there are multiple rounds */}
|
||||
{versions.length > 1 && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<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>
|
||||
)}
|
||||
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-3">{t('artefacts.versions')}</h4>
|
||||
<ArtefactVersionTimeline
|
||||
versions={versions}
|
||||
activeVersionId={selectedVersion?.Id}
|
||||
@@ -132,6 +107,7 @@ export function ArtefactDetailVersionsTab({
|
||||
artefactType={artefact.type}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Type-specific content */}
|
||||
{versionData && selectedVersion && (
|
||||
@@ -145,7 +121,7 @@ export function ArtefactDetailVersionsTab({
|
||||
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"
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
<Globe className="w-3 h-3" />
|
||||
{t('artefacts.addLanguage')}
|
||||
</button>
|
||||
</div>
|
||||
@@ -161,13 +137,22 @@ export function ArtefactDetailVersionsTab({
|
||||
</span>
|
||||
<span className="text-sm font-medium text-text-primary">{text.language_label}</span>
|
||||
</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
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-surface rounded border border-border p-3 text-sm text-text-primary whitespace-pre-wrap font-sans">
|
||||
{text.content}
|
||||
</div>
|
||||
@@ -345,43 +330,39 @@ export function ArtefactDetailVersionsTab({
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* New Version Modal */}
|
||||
<Modal isOpen={showNewVersionModal} onClose={() => setShowNewVersionModal(false)} title={t('artefacts.createNewVersion')} size="sm">
|
||||
{/* Edit Language Modal */}
|
||||
<Modal isOpen={!!editingLangText} onClose={() => setEditingLangText(null)} title={t('artefacts.editLanguage')} size="md">
|
||||
<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>
|
||||
<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
|
||||
value={newVersionNotes}
|
||||
onChange={e => setNewVersionNotes(e.target.value)}
|
||||
rows={3}
|
||||
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"
|
||||
placeholder={t('artefacts.whatChanged')}
|
||||
value={editLangContent}
|
||||
onChange={e => setEditLangContent(e.target.value)}
|
||||
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 font-sans"
|
||||
/>
|
||||
</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">
|
||||
<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"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCreateVersion}
|
||||
disabled={creatingVersion}
|
||||
onClick={handleSaveEditLang}
|
||||
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"
|
||||
>
|
||||
{creatingVersion ? t('artefacts.creatingVersion') : t('artefacts.createVersion')}
|
||||
{savingEditLang ? t('header.saving') : t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
"common.loading": "جاري التحميل...",
|
||||
"common.unassigned": "غير مُسند",
|
||||
"common.close": "إغلاق",
|
||||
"common.created": "تاريخ الإنشاء",
|
||||
"common.required": "مطلوب",
|
||||
"common.saveFailed": "فشل الحفظ. حاول مجدداً.",
|
||||
"common.updateFailed": "فشل التحديث. حاول مجدداً.",
|
||||
@@ -761,6 +762,9 @@
|
||||
"issues.trackingLinkCopied": "تم نسخ رابط التتبع!",
|
||||
"issues.deleteAttachment": "حذف المرفق؟",
|
||||
"issues.deleteAttachmentDesc": "لا يمكن التراجع عن هذا الإجراء.",
|
||||
"artefacts.editLanguage": "تعديل اللغة",
|
||||
"artefacts.linkedPost": "المنشور المرتبط",
|
||||
"artefacts.post": "منشور",
|
||||
"artefacts.deleteLanguage": "حذف هذه اللغة؟",
|
||||
"artefacts.deleteLanguageDesc": "سيتم إزالة المحتوى لهذه اللغة.",
|
||||
"artefacts.deleteAttachment": "حذف هذا المرفق؟",
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
"common.loading": "Loading...",
|
||||
"common.unassigned": "Unassigned",
|
||||
"common.close": "Close",
|
||||
"common.created": "Created",
|
||||
"common.required": "Required",
|
||||
"common.saveFailed": "Failed to save. Please try again.",
|
||||
"common.updateFailed": "Failed to update. Please try again.",
|
||||
@@ -761,6 +762,9 @@
|
||||
"issues.trackingLinkCopied": "Tracking link copied to clipboard!",
|
||||
"issues.deleteAttachment": "Delete attachment?",
|
||||
"issues.deleteAttachmentDesc": "This action cannot be undone.",
|
||||
"artefacts.editLanguage": "Edit Language",
|
||||
"artefacts.linkedPost": "Linked Post",
|
||||
"artefacts.post": "Post",
|
||||
"artefacts.deleteLanguage": "Delete this language?",
|
||||
"artefacts.deleteLanguageDesc": "The content for this language will be removed.",
|
||||
"artefacts.deleteAttachment": "Delete this attachment?",
|
||||
|
||||
+100
-104
@@ -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,79 +342,32 @@ 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">
|
||||
{ASSET_TYPES.map(type => (
|
||||
<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')}
|
||||
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}
|
||||
@@ -398,6 +377,7 @@ export default function PostDetail() {
|
||||
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>
|
||||
)}
|
||||
|
||||
@@ -40,17 +40,25 @@ function buildWhere(conditions) {
|
||||
.join('~and');
|
||||
}
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 20_000;
|
||||
|
||||
async function request(method, url, body) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
||||
|
||||
const opts = {
|
||||
method,
|
||||
headers: {
|
||||
'xc-token': NOCODB_TOKEN,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
signal: controller.signal,
|
||||
};
|
||||
if (body !== undefined) opts.body = JSON.stringify(body);
|
||||
|
||||
try {
|
||||
const res = await fetch(url, opts);
|
||||
clearTimeout(timer);
|
||||
if (!res.ok) {
|
||||
let details;
|
||||
try { details = await res.json(); } catch {}
|
||||
@@ -63,6 +71,13 @@ async function request(method, url, body) {
|
||||
// DELETE returns empty or {msg}
|
||||
const text = await res.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 ─────────────────────────────────────────
|
||||
|
||||
+103
-5
@@ -591,12 +591,17 @@ async function ensureFKColumns() {
|
||||
|
||||
for (const col of columns) {
|
||||
if (!existingCols.has(col)) {
|
||||
console.log(` Adding column ${table}.${col}...`);
|
||||
await fetch(`${nocodb.url}/api/v2/meta/tables/${tableId}/columns`, {
|
||||
console.log(` Adding FK column ${table}.${col}...`);
|
||||
const colRes = await fetch(`${nocodb.url}/api/v2/meta/tables/${tableId}/columns`, {
|
||||
method: 'POST',
|
||||
headers: { 'xc-token': nocodb.token, 'Content-Type': 'application/json' },
|
||||
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) {
|
||||
@@ -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 ───────────────────────────────────────────────
|
||||
|
||||
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) => {
|
||||
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' });
|
||||
|
||||
try {
|
||||
@@ -4018,6 +4045,7 @@ app.post('/api/artefacts', requireAuth, async (req, res) => {
|
||||
status: 'draft',
|
||||
content: content || 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,
|
||||
campaign_id: campaign_id ? Number(campaign_id) : 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,
|
||||
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);
|
||||
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);
|
||||
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 approvers = [];
|
||||
for (const id of approverIdList) {
|
||||
@@ -4267,6 +4295,45 @@ app.post('/api/artefacts/:id/link-post', requireAuth, async (req, res) => {
|
||||
|
||||
// ─── 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
|
||||
app.get('/api/artefacts/:id/versions', requireAuth, async (req, res) => {
|
||||
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
|
||||
app.delete('/api/artefact-version-texts/:id', requireAuth, async (req, res) => {
|
||||
try {
|
||||
@@ -4823,6 +4915,9 @@ app.post('/api/public/review/:token/reject', async (req, res) => {
|
||||
feedback: feedback || '',
|
||||
});
|
||||
|
||||
// Auto-advance to next working version
|
||||
await autoAdvanceArtefactVersion(artefact, nocodb, QUERY_LIMITS);
|
||||
|
||||
res.json({ success: true, message: 'Artefact rejected' });
|
||||
notify.notifyRejected({ type: 'artefact', record: artefact, approverName: approved_by_name, feedback });
|
||||
} catch (err) {
|
||||
@@ -4853,6 +4948,9 @@ app.post('/api/public/review/:token/revision', async (req, res) => {
|
||||
feedback: feedback || '',
|
||||
});
|
||||
|
||||
// Auto-advance to next working version
|
||||
await autoAdvanceArtefactVersion(artefact, nocodb, QUERY_LIMITS);
|
||||
|
||||
res.json({ success: true, message: 'Revision requested' });
|
||||
notify.notifyRevisionRequested({ record: artefact, approverName: approved_by_name, feedback });
|
||||
} catch (err) {
|
||||
|
||||
Reference in New Issue
Block a user