From 2759aa87f6c836219c429fc6f1e5e461e6f6bea4 Mon Sep 17 00:00:00 2001
From: 4Ventures <4ventures@MacBook-Air-de-4Ventures.local>
Date: Wed, 7 Oct 2026 13:46:48 +0200
Subject: [PATCH] Enable live Meta caption updates and partial IG/FB deletes
from the pipeline.
Facebook edits in place when possible; Instagram deletes and republishes. Trash and delete-meta no longer block when only one network succeeds.
Co-authored-by: Cursor
---
.../api/creative-pipeline/publish/route.ts | 133 +++++++++--
src/components/CreativePipelineBoard.tsx | 128 ++++++++--
src/lib/meta-publish.ts | 225 ++++++++++++++++--
3 files changed, 431 insertions(+), 55 deletions(-)
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) && (