diff --git a/src/app/api/creative-pipeline/publish/route.ts b/src/app/api/creative-pipeline/publish/route.ts index 3b05ba8..4b891d0 100644 --- a/src/app/api/creative-pipeline/publish/route.ts +++ b/src/app/api/creative-pipeline/publish/route.ts @@ -1,9 +1,7 @@ /** - * Agency: publish now / delete on Meta (IG+FB) for a pipeline item. + * Agency Meta actions for a pipeline item. * - * POST { id, action: 'publish_now' | 'delete_meta' } - * delete_meta removes the live IG/FB posts and clears Meta ids - * (pipeline row stays; use DELETE /api/creative-pipeline to remove row). + * POST { id, action: 'publish_now' | 'delete_meta' | 'update_live', caption? } */ import { NextRequest, NextResponse } from 'next/server'; import { createClient } from '@/lib/supabase/server'; @@ -18,6 +16,7 @@ import { deleteMetaPosts, mergePermalinkNotes, publishPipelineItem, + updateLivePipelineItem, type PipelinePublishRow, } from '@/lib/meta-publish'; @@ -31,7 +30,7 @@ async function loadItem( id: string, tenantId: string, headers: Record -): Promise { +): Promise<(PipelinePublishRow & { notes?: string | null; status?: string }) | null> { const r = await fetch( `${supabaseUrl()}/rest/v1/creative_pipeline?id=eq.${encodeURIComponent( id @@ -91,38 +90,127 @@ export async function POST(req: NextRequest) { const body = (await req.json().catch(() => ({}))) as { id?: string; action?: string; + caption?: string; }; const id = typeof body.id === 'string' ? body.id.trim() : ''; - const action = body.action === 'delete_meta' ? 'delete_meta' : 'publish_now'; + const action = + body.action === 'delete_meta' + ? 'delete_meta' + : body.action === 'update_live' + ? 'update_live' + : 'publish_now'; if (!id) { return NextResponse.json({ error: 'id requerido' }, { status: 400 }); } - const item = await loadItem(id, ctx.tenantId, headers); + let item = await loadItem(id, ctx.tenantId, headers); if (!item) { return NextResponse.json({ error: 'Pieza no encontrada' }, { status: 404 }); } + // Optional caption override before update/publish + if (typeof body.caption === 'string') { + const caption = body.caption.trim().slice(0, 4000); + await patchItem(id, headers, { caption: caption || null }); + item = { ...item, caption: caption || null }; + } + if (action === 'delete_meta') { const del = await deleteMetaPosts(item); - if (!del.ok) { + const patchBody: Record = { + publish_error: del.ok + ? null + : del.error || 'Borrado parcial en Meta', + }; + if (del.clearIg) patchBody.meta_ig_media_id = null; + if (del.clearFb) patchBody.meta_fb_post_id = null; + if (del.clearIg && del.clearFb) { + patchBody.publish_status = 'idle'; + patchBody.published_at = null; + } else if (del.partialOk) { + // Still live on one network + patchBody.publish_status = 'published'; + } + const patch = await patchItem(id, headers, patchBody); + const rows = patch.ok ? await patch.json() : null; + if (!del.ok && !del.partialOk) { return NextResponse.json( - { error: del.error || 'No se pudo borrar en Meta' }, + { + error: del.error || 'No se pudo borrar en Meta', + item: Array.isArray(rows) ? rows[0] : null, + igDeleted: del.igDeleted, + fbDeleted: del.fbDeleted, + }, { status: 502 } ); } - const patch = await patchItem(id, headers, { - meta_ig_media_id: null, - meta_fb_post_id: null, - publish_status: 'idle', - publish_error: null, - published_at: null, + return NextResponse.json({ + ok: del.ok, + partial: !del.ok && del.partialOk, + action: 'delete_meta', + igDeleted: del.igDeleted, + fbDeleted: del.fbDeleted, + warning: del.ok ? null : del.error, + item: Array.isArray(rows) ? rows[0] : null, }); - const rows = patch.ok ? await patch.json() : null; + } + + if (action === 'update_live') { + if (!item.cover_path) { + return NextResponse.json( + { error: 'Sube una portada antes de actualizar en redes' }, + { status: 400 } + ); + } + if (!item.meta_ig_media_id && !item.meta_fb_post_id) { + return NextResponse.json( + { error: 'Esta pieza no está publicada en redes todavía' }, + { status: 400 } + ); + } + await patchItem(id, headers, { + publish_status: 'publishing', + publish_error: null, + }); + const result = await updateLivePipelineItem(item); + if (!result.ok) { + const fail = await patchItem(id, headers, { + publish_status: 'failed', + publish_error: result.error || 'Error al actualizar en redes', + meta_ig_media_id: result.igMediaId ?? item.meta_ig_media_id, + meta_fb_post_id: result.fbPostId ?? item.meta_fb_post_id, + }); + const rows = fail.ok ? await fail.json() : null; + return NextResponse.json( + { + error: result.error || 'Error al actualizar en redes', + detail: result.detail, + item: Array.isArray(rows) ? rows[0] : null, + }, + { status: 502 } + ); + } + const notes = mergePermalinkNotes( + (item as { notes?: string | null }).notes, + result.igPermalink, + null + ); + const okPatch = await patchItem(id, headers, { + publish_status: 'published', + publish_error: null, + published_at: new Date().toISOString(), + status: 'publicado', + meta_ig_media_id: result.igMediaId || null, + meta_fb_post_id: result.fbPostId || null, + notes, + }); + const rows = okPatch.ok ? await okPatch.json() : null; return NextResponse.json({ ok: true, - action: 'delete_meta', - cleared: del.cleared, + action: 'update_live', + detail: result.detail, + igMediaId: result.igMediaId, + fbPostId: result.fbPostId, item: Array.isArray(rows) ? rows[0] : null, }); } @@ -141,12 +229,15 @@ export async function POST(req: NextRequest) { publish_attempts: attempts, publish_error: null, scheduled_at: new Date().toISOString(), - status: item.status === 'aprobado' || item.status === 'publicado' - ? item.status - : 'aprobado', + status: + item.status === 'aprobado' || item.status === 'publicado' + ? item.status + : 'aprobado', }); + // Skips networks that already have an id (use update_live to replace). const result = await publishPipelineItem(item); + if (!result.ok) { const fail = await patchItem(id, headers, { publish_status: 'failed', diff --git a/src/components/CreativePipelineBoard.tsx b/src/components/CreativePipelineBoard.tsx index 1afd1e2..74cde3f 100644 --- a/src/components/CreativePipelineBoard.tsx +++ b/src/components/CreativePipelineBoard.tsx @@ -573,8 +573,9 @@ export default function CreativePipelineBoard() { async function removeItem(id: string, alsoMeta = false) { setBusyId(id); setErr(null); + setWarning(null); try { - const target = items.find((x) => x.id === id); + const target = items.find((x) => x.id === id) || previewItem; if ( alsoMeta && (target?.meta_ig_media_id || target?.meta_fb_post_id) @@ -585,14 +586,31 @@ export default function CreativePipelineBoard() { body: JSON.stringify({ id, action: 'delete_meta' }), }); const delData = await del.json(); - if (!del.ok) { + // Partial Meta delete still allows removing the pipeline row. + if (!del.ok && !delData.partial && !delData.igDeleted && !delData.fbDeleted) { setErr(delData.error || 'No se pudo borrar en Meta'); + if (delData.item && previewItem?.id === id) { + setPreviewItem({ ...previewItem, ...delData.item }); + } + await load(); return; } + if (delData.warning || delData.partial) { + setWarning( + `Borrado en redes parcial: ${delData.warning || delData.error || 'revisa IG/FB'}. Eliminando del pipeline…` + ); + } + } + const delRow = await fetch( + `/api/creative-pipeline?id=${encodeURIComponent(id)}`, + { method: 'DELETE' } + ); + if (!delRow.ok) { + const j = await delRow.json().catch(() => ({})); + setErr(j.error || 'No se pudo eliminar la pieza del pipeline'); + await load(); + return; } - await fetch(`/api/creative-pipeline?id=${encodeURIComponent(id)}`, { - method: 'DELETE', - }); if (previewItem?.id === id) setPreviewItem(null); await load(); } finally { @@ -632,13 +650,14 @@ export default function CreativePipelineBoard() { if (!isAgency) return; if ( !window.confirm( - '¿Borrar esta publicación en Instagram/Facebook? La pieza seguirá en el pipeline.' + '¿Borrar esta publicación en Instagram y Facebook? La pieza seguirá en el pipeline.' ) ) { return; } setBusyId(id); setErr(null); + setWarning(null); try { const res = await fetch('/api/creative-pipeline/publish', { method: 'POST', @@ -646,10 +665,58 @@ export default function CreativePipelineBoard() { body: JSON.stringify({ id, action: 'delete_meta' }), }); const data = await res.json(); - if (!res.ok) { + if (!res.ok && !data.partial) { setErr(data.error || 'No se pudo borrar en Meta'); + if (data.item && previewItem?.id === id) { + setPreviewItem({ ...previewItem, ...data.item }); + } + await load(); return; } + if (data.warning || data.partial) { + setWarning( + `Borrado parcial — IG: ${data.igDeleted ? 'ok' : 'falló'} · FB: ${ + data.fbDeleted ? 'ok' : 'falló' + }. ${data.warning || data.error || ''}` + ); + } + if (data.item && previewItem?.id === id) { + setPreviewItem({ ...previewItem, ...data.item }); + } + await load(); + } finally { + setBusyId(null); + } + } + + async function updateLiveOnMeta(id: string, caption: string) { + if (!isAgency) return; + if ( + !window.confirm( + '¿Actualizar en redes?\n\n• Facebook: intenta editar el texto.\n• Instagram: borra y republica (Meta no permite editar captions).' + ) + ) { + return; + } + setBusyId(id); + setErr(null); + setWarning(null); + try { + const res = await fetch('/api/creative-pipeline/publish', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id, action: 'update_live', caption }), + }); + const data = await res.json(); + if (!res.ok) { + setErr(data.error || 'No se pudo actualizar en redes'); + if (data.item && previewItem?.id === id) { + setPreviewItem({ ...previewItem, ...data.item }); + } + await load(); + return; + } + if (data.detail) setWarning(`Actualizado en redes: ${data.detail}`); if (data.item && previewItem?.id === id) { setPreviewItem({ ...previewItem, ...data.item }); } @@ -1160,6 +1227,9 @@ export default function CreativePipelineBoard() { onSaveContent={(payload) => saveContent(previewItem.id, payload)} onPublishNow={() => publishNow(previewItem.id)} onDeleteMeta={() => deleteOnMeta(previewItem.id)} + onUpdateLive={(caption) => + updateLiveOnMeta(previewItem.id, caption) + } /> )} @@ -1464,6 +1534,7 @@ function DetailModal({ onSaveContent, onPublishNow, onDeleteMeta, + onUpdateLive, }: { item: PipelineItem; isAgency: boolean; @@ -1484,6 +1555,7 @@ function DetailModal({ }) => void; onPublishNow: () => void; onDeleteMeta: () => void; + onUpdateLive: (caption: string) => void; }) { const slides = item.slide_paths || []; const allImages = [item.cover_path, ...slides].filter(Boolean) as string[]; @@ -1753,14 +1825,30 @@ function DetailModal({ Sin enlace a calendario — solo se guarda en el pipeline.

)} - +
+ + {(item.meta_ig_media_id || item.meta_fb_post_id) && ( + + )} +

+ En redes: FB edita el texto si puede; IG siempre republica + (Meta no deja editar captions publicados). +

+
) : item.caption ? (
@@ -1825,7 +1913,7 @@ function DetailModal({ busy || !item.cover_path || item.publish_status === 'publishing' || - item.publish_status === 'published' + Boolean(item.meta_ig_media_id && item.meta_fb_post_id) } onClick={onPublishNow} className="inline-flex w-full items-center justify-center gap-1.5 rounded-lg bg-gradient-to-r from-purple-600 via-pink-600 to-amber-500 px-3 py-2 text-xs font-bold text-white shadow-sm hover:opacity-95 disabled:opacity-50" @@ -1833,9 +1921,11 @@ function DetailModal({ {item.publish_status === 'publishing' ? 'Publicando…' - : item.publish_status === 'published' - ? 'Ya publicado en redes' - : 'Publicar ahora (IG + FB)'} + : item.meta_ig_media_id && item.meta_fb_post_id + ? 'Ya publicado en IG + FB' + : item.meta_ig_media_id || item.meta_fb_post_id + ? 'Completar publicación (red faltante)' + : 'Publicar ahora (IG + FB)'} {(item.meta_ig_media_id || item.meta_fb_post_id) && (