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 <cursoragent@cursor.com>
This commit is contained in:
@@ -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' }
|
* POST { id, action: 'publish_now' | 'delete_meta' | 'update_live', caption? }
|
||||||
* delete_meta removes the live IG/FB posts and clears Meta ids
|
|
||||||
* (pipeline row stays; use DELETE /api/creative-pipeline to remove row).
|
|
||||||
*/
|
*/
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { createClient } from '@/lib/supabase/server';
|
import { createClient } from '@/lib/supabase/server';
|
||||||
@@ -18,6 +16,7 @@ import {
|
|||||||
deleteMetaPosts,
|
deleteMetaPosts,
|
||||||
mergePermalinkNotes,
|
mergePermalinkNotes,
|
||||||
publishPipelineItem,
|
publishPipelineItem,
|
||||||
|
updateLivePipelineItem,
|
||||||
type PipelinePublishRow,
|
type PipelinePublishRow,
|
||||||
} from '@/lib/meta-publish';
|
} from '@/lib/meta-publish';
|
||||||
|
|
||||||
@@ -31,7 +30,7 @@ async function loadItem(
|
|||||||
id: string,
|
id: string,
|
||||||
tenantId: string,
|
tenantId: string,
|
||||||
headers: Record<string, string>
|
headers: Record<string, string>
|
||||||
): Promise<PipelinePublishRow & { notes?: string | null; status?: string } | null> {
|
): Promise<(PipelinePublishRow & { notes?: string | null; status?: string }) | null> {
|
||||||
const r = await fetch(
|
const r = await fetch(
|
||||||
`${supabaseUrl()}/rest/v1/creative_pipeline?id=eq.${encodeURIComponent(
|
`${supabaseUrl()}/rest/v1/creative_pipeline?id=eq.${encodeURIComponent(
|
||||||
id
|
id
|
||||||
@@ -91,38 +90,127 @@ export async function POST(req: NextRequest) {
|
|||||||
const body = (await req.json().catch(() => ({}))) as {
|
const body = (await req.json().catch(() => ({}))) as {
|
||||||
id?: string;
|
id?: string;
|
||||||
action?: string;
|
action?: string;
|
||||||
|
caption?: string;
|
||||||
};
|
};
|
||||||
const id = typeof body.id === 'string' ? body.id.trim() : '';
|
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) {
|
if (!id) {
|
||||||
return NextResponse.json({ error: 'id requerido' }, { status: 400 });
|
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) {
|
if (!item) {
|
||||||
return NextResponse.json({ error: 'Pieza no encontrada' }, { status: 404 });
|
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') {
|
if (action === 'delete_meta') {
|
||||||
const del = await deleteMetaPosts(item);
|
const del = await deleteMetaPosts(item);
|
||||||
if (!del.ok) {
|
const patchBody: Record<string, unknown> = {
|
||||||
|
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(
|
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 }
|
{ status: 502 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const patch = await patchItem(id, headers, {
|
return NextResponse.json({
|
||||||
meta_ig_media_id: null,
|
ok: del.ok,
|
||||||
meta_fb_post_id: null,
|
partial: !del.ok && del.partialOk,
|
||||||
publish_status: 'idle',
|
action: 'delete_meta',
|
||||||
publish_error: null,
|
igDeleted: del.igDeleted,
|
||||||
published_at: null,
|
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({
|
return NextResponse.json({
|
||||||
ok: true,
|
ok: true,
|
||||||
action: 'delete_meta',
|
action: 'update_live',
|
||||||
cleared: del.cleared,
|
detail: result.detail,
|
||||||
|
igMediaId: result.igMediaId,
|
||||||
|
fbPostId: result.fbPostId,
|
||||||
item: Array.isArray(rows) ? rows[0] : null,
|
item: Array.isArray(rows) ? rows[0] : null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -141,12 +229,15 @@ export async function POST(req: NextRequest) {
|
|||||||
publish_attempts: attempts,
|
publish_attempts: attempts,
|
||||||
publish_error: null,
|
publish_error: null,
|
||||||
scheduled_at: new Date().toISOString(),
|
scheduled_at: new Date().toISOString(),
|
||||||
status: item.status === 'aprobado' || item.status === 'publicado'
|
status:
|
||||||
|
item.status === 'aprobado' || item.status === 'publicado'
|
||||||
? item.status
|
? item.status
|
||||||
: 'aprobado',
|
: 'aprobado',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Skips networks that already have an id (use update_live to replace).
|
||||||
const result = await publishPipelineItem(item);
|
const result = await publishPipelineItem(item);
|
||||||
|
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
const fail = await patchItem(id, headers, {
|
const fail = await patchItem(id, headers, {
|
||||||
publish_status: 'failed',
|
publish_status: 'failed',
|
||||||
|
|||||||
@@ -573,8 +573,9 @@ export default function CreativePipelineBoard() {
|
|||||||
async function removeItem(id: string, alsoMeta = false) {
|
async function removeItem(id: string, alsoMeta = false) {
|
||||||
setBusyId(id);
|
setBusyId(id);
|
||||||
setErr(null);
|
setErr(null);
|
||||||
|
setWarning(null);
|
||||||
try {
|
try {
|
||||||
const target = items.find((x) => x.id === id);
|
const target = items.find((x) => x.id === id) || previewItem;
|
||||||
if (
|
if (
|
||||||
alsoMeta &&
|
alsoMeta &&
|
||||||
(target?.meta_ig_media_id || target?.meta_fb_post_id)
|
(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' }),
|
body: JSON.stringify({ id, action: 'delete_meta' }),
|
||||||
});
|
});
|
||||||
const delData = await del.json();
|
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');
|
setErr(delData.error || 'No se pudo borrar en Meta');
|
||||||
|
if (delData.item && previewItem?.id === id) {
|
||||||
|
setPreviewItem({ ...previewItem, ...delData.item });
|
||||||
|
}
|
||||||
|
await load();
|
||||||
return;
|
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);
|
if (previewItem?.id === id) setPreviewItem(null);
|
||||||
await load();
|
await load();
|
||||||
} finally {
|
} finally {
|
||||||
@@ -632,13 +650,14 @@ export default function CreativePipelineBoard() {
|
|||||||
if (!isAgency) return;
|
if (!isAgency) return;
|
||||||
if (
|
if (
|
||||||
!window.confirm(
|
!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;
|
return;
|
||||||
}
|
}
|
||||||
setBusyId(id);
|
setBusyId(id);
|
||||||
setErr(null);
|
setErr(null);
|
||||||
|
setWarning(null);
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/creative-pipeline/publish', {
|
const res = await fetch('/api/creative-pipeline/publish', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -646,10 +665,58 @@ export default function CreativePipelineBoard() {
|
|||||||
body: JSON.stringify({ id, action: 'delete_meta' }),
|
body: JSON.stringify({ id, action: 'delete_meta' }),
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (!res.ok) {
|
if (!res.ok && !data.partial) {
|
||||||
setErr(data.error || 'No se pudo borrar en Meta');
|
setErr(data.error || 'No se pudo borrar en Meta');
|
||||||
|
if (data.item && previewItem?.id === id) {
|
||||||
|
setPreviewItem({ ...previewItem, ...data.item });
|
||||||
|
}
|
||||||
|
await load();
|
||||||
return;
|
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) {
|
if (data.item && previewItem?.id === id) {
|
||||||
setPreviewItem({ ...previewItem, ...data.item });
|
setPreviewItem({ ...previewItem, ...data.item });
|
||||||
}
|
}
|
||||||
@@ -1160,6 +1227,9 @@ export default function CreativePipelineBoard() {
|
|||||||
onSaveContent={(payload) => saveContent(previewItem.id, payload)}
|
onSaveContent={(payload) => saveContent(previewItem.id, payload)}
|
||||||
onPublishNow={() => publishNow(previewItem.id)}
|
onPublishNow={() => publishNow(previewItem.id)}
|
||||||
onDeleteMeta={() => deleteOnMeta(previewItem.id)}
|
onDeleteMeta={() => deleteOnMeta(previewItem.id)}
|
||||||
|
onUpdateLive={(caption) =>
|
||||||
|
updateLiveOnMeta(previewItem.id, caption)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1464,6 +1534,7 @@ function DetailModal({
|
|||||||
onSaveContent,
|
onSaveContent,
|
||||||
onPublishNow,
|
onPublishNow,
|
||||||
onDeleteMeta,
|
onDeleteMeta,
|
||||||
|
onUpdateLive,
|
||||||
}: {
|
}: {
|
||||||
item: PipelineItem;
|
item: PipelineItem;
|
||||||
isAgency: boolean;
|
isAgency: boolean;
|
||||||
@@ -1484,6 +1555,7 @@ function DetailModal({
|
|||||||
}) => void;
|
}) => void;
|
||||||
onPublishNow: () => void;
|
onPublishNow: () => void;
|
||||||
onDeleteMeta: () => void;
|
onDeleteMeta: () => void;
|
||||||
|
onUpdateLive: (caption: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const slides = item.slide_paths || [];
|
const slides = item.slide_paths || [];
|
||||||
const allImages = [item.cover_path, ...slides].filter(Boolean) as string[];
|
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.
|
Sin enlace a calendario — solo se guarda en el pipeline.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={busy || !editTitle.trim()}
|
disabled={busy || !editTitle.trim()}
|
||||||
onClick={submitContent}
|
onClick={submitContent}
|
||||||
className="w-full rounded-lg bg-gray-900 px-3 py-2 text-xs font-semibold text-white hover:bg-gray-800 disabled:opacity-60"
|
className="w-full rounded-lg bg-gray-900 px-3 py-2 text-xs font-semibold text-white hover:bg-gray-800 disabled:opacity-60"
|
||||||
>
|
>
|
||||||
Guardar cambios
|
Guardar cambios (portal)
|
||||||
</button>
|
</button>
|
||||||
|
{(item.meta_ig_media_id || item.meta_fb_post_id) && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy || !editTitle.trim()}
|
||||||
|
onClick={() => onUpdateLive(editCaption)}
|
||||||
|
className="w-full rounded-lg border border-violet-300 bg-violet-50 px-3 py-2 text-xs font-semibold text-violet-900 hover:bg-violet-100 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
Actualizar en Instagram / Facebook
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<p className="text-[10px] text-gray-500 leading-relaxed">
|
||||||
|
En redes: FB edita el texto si puede; IG siempre republica
|
||||||
|
(Meta no deja editar captions publicados).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : item.caption ? (
|
) : item.caption ? (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
@@ -1825,7 +1913,7 @@ function DetailModal({
|
|||||||
busy ||
|
busy ||
|
||||||
!item.cover_path ||
|
!item.cover_path ||
|
||||||
item.publish_status === 'publishing' ||
|
item.publish_status === 'publishing' ||
|
||||||
item.publish_status === 'published'
|
Boolean(item.meta_ig_media_id && item.meta_fb_post_id)
|
||||||
}
|
}
|
||||||
onClick={onPublishNow}
|
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"
|
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,8 +1921,10 @@ function DetailModal({
|
|||||||
<Send className="h-3.5 w-3.5" />
|
<Send className="h-3.5 w-3.5" />
|
||||||
{item.publish_status === 'publishing'
|
{item.publish_status === 'publishing'
|
||||||
? 'Publicando…'
|
? 'Publicando…'
|
||||||
: item.publish_status === 'published'
|
: item.meta_ig_media_id && item.meta_fb_post_id
|
||||||
? 'Ya publicado en redes'
|
? 'Ya publicado en IG + FB'
|
||||||
|
: item.meta_ig_media_id || item.meta_fb_post_id
|
||||||
|
? 'Completar publicación (red faltante)'
|
||||||
: 'Publicar ahora (IG + FB)'}
|
: 'Publicar ahora (IG + FB)'}
|
||||||
</button>
|
</button>
|
||||||
{(item.meta_ig_media_id || item.meta_fb_post_id) && (
|
{(item.meta_ig_media_id || item.meta_fb_post_id) && (
|
||||||
|
|||||||
@@ -555,7 +555,8 @@ async function publishFbPost(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function publishPipelineItem(
|
export async function publishPipelineItem(
|
||||||
item: PipelinePublishRow
|
item: PipelinePublishRow,
|
||||||
|
opts?: { forceIg?: boolean; forceFb?: boolean; skipIg?: boolean; skipFb?: boolean }
|
||||||
): Promise<PublishResult> {
|
): Promise<PublishResult> {
|
||||||
const channels = normalizeChannels(item.channels);
|
const channels = normalizeChannels(item.channels);
|
||||||
const resolved = await resolveTenantMetaTargets(item.tenant_id);
|
const resolved = await resolveTenantMetaTargets(item.tenant_id);
|
||||||
@@ -564,13 +565,16 @@ export async function publishPipelineItem(
|
|||||||
}
|
}
|
||||||
const { pageId, igUserId, pageToken } = resolved.targets;
|
const { pageId, igUserId, pageToken } = resolved.targets;
|
||||||
|
|
||||||
let igMediaId: string | null = item.meta_ig_media_id || null;
|
let igMediaId: string | null = opts?.forceIg ? null : item.meta_ig_media_id || null;
|
||||||
let fbPostId: string | null = item.meta_fb_post_id || null;
|
let fbPostId: string | null = opts?.forceFb ? null : item.meta_fb_post_id || null;
|
||||||
let igPermalink: string | null = null;
|
let igPermalink: string | null = null;
|
||||||
let fbPermalink: string | null = null;
|
let fbPermalink: string | null = null;
|
||||||
const errors: string[] = [];
|
const errors: string[] = [];
|
||||||
|
|
||||||
if (channels.includes('instagram')) {
|
const wantIg = channels.includes('instagram') && !opts?.skipIg;
|
||||||
|
const wantFb = channels.includes('facebook') && !opts?.skipFb;
|
||||||
|
|
||||||
|
if (wantIg) {
|
||||||
if (!igUserId) {
|
if (!igUserId) {
|
||||||
errors.push('Sin Instagram Business vinculado a la Página');
|
errors.push('Sin Instagram Business vinculado a la Página');
|
||||||
} else if (!igMediaId) {
|
} else if (!igMediaId) {
|
||||||
@@ -584,7 +588,7 @@ export async function publishPipelineItem(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (channels.includes('facebook')) {
|
if (wantFb) {
|
||||||
if (!fbPostId) {
|
if (!fbPostId) {
|
||||||
const fb = await publishFbPost(pageId, pageToken, item);
|
const fb = await publishFbPost(pageId, pageToken, item);
|
||||||
if (fb.error || !fb.id) {
|
if (fb.error || !fb.id) {
|
||||||
@@ -596,8 +600,6 @@ export async function publishPipelineItem(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const wantIg = channels.includes('instagram');
|
|
||||||
const wantFb = channels.includes('facebook');
|
|
||||||
const igOk = !wantIg || Boolean(igMediaId);
|
const igOk = !wantIg || Boolean(igMediaId);
|
||||||
const fbOk = !wantFb || Boolean(fbPostId);
|
const fbOk = !wantFb || Boolean(fbPostId);
|
||||||
const ok = igOk && fbOk && errors.length === 0;
|
const ok = igOk && fbOk && errors.length === 0;
|
||||||
@@ -613,36 +615,229 @@ export async function publishPipelineItem(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type DeleteMetaResult = {
|
||||||
|
ok: boolean;
|
||||||
|
/** At least one network deleted or nothing to delete */
|
||||||
|
partialOk: boolean;
|
||||||
|
error?: string;
|
||||||
|
igDeleted: boolean;
|
||||||
|
fbDeleted: boolean;
|
||||||
|
/** Clear these ids in DB after the call */
|
||||||
|
clearIg: boolean;
|
||||||
|
clearFb: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function deleteFacebookObject(
|
||||||
|
fbId: string,
|
||||||
|
pageToken: string,
|
||||||
|
pageId: string
|
||||||
|
): Promise<{ ok: boolean; error?: string }> {
|
||||||
|
const candidates = [fbId];
|
||||||
|
// pageId_postId → also try bare post id
|
||||||
|
if (fbId.includes('_')) {
|
||||||
|
const bare = fbId.split('_').slice(1).join('_');
|
||||||
|
if (bare) candidates.push(bare);
|
||||||
|
}
|
||||||
|
// Sometimes we stored photo id; try deleting as page post via feed
|
||||||
|
candidates.push(`${pageId}_${fbId}`);
|
||||||
|
|
||||||
|
const errors: string[] = [];
|
||||||
|
for (const cand of [...new Set(candidates)]) {
|
||||||
|
const d = await graphDelete(encodeURIComponent(cand), pageToken);
|
||||||
|
if (d.ok) return { ok: true };
|
||||||
|
// Already gone counts as success
|
||||||
|
const msg = formatGraphError(d.raw).toLowerCase();
|
||||||
|
if (
|
||||||
|
msg.includes('does not exist') ||
|
||||||
|
msg.includes('unsupported get request') ||
|
||||||
|
msg.includes('no existe') ||
|
||||||
|
msg.includes('(#100)')
|
||||||
|
) {
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
errors.push(formatGraphError(d.raw));
|
||||||
|
}
|
||||||
|
return { ok: false, error: errors[0] || 'No se pudo borrar en Facebook' };
|
||||||
|
}
|
||||||
|
|
||||||
export async function deleteMetaPosts(item: {
|
export async function deleteMetaPosts(item: {
|
||||||
tenant_id: string;
|
tenant_id: string;
|
||||||
meta_ig_media_id?: string | null;
|
meta_ig_media_id?: string | null;
|
||||||
meta_fb_post_id?: string | null;
|
meta_fb_post_id?: string | null;
|
||||||
}): Promise<{ ok: boolean; error?: string; cleared: boolean }> {
|
}): Promise<DeleteMetaResult> {
|
||||||
const igId = item.meta_ig_media_id?.trim();
|
const igId = item.meta_ig_media_id?.trim();
|
||||||
const fbId = item.meta_fb_post_id?.trim();
|
const fbId = item.meta_fb_post_id?.trim();
|
||||||
if (!igId && !fbId) {
|
if (!igId && !fbId) {
|
||||||
return { ok: true, cleared: false };
|
return {
|
||||||
|
ok: true,
|
||||||
|
partialOk: true,
|
||||||
|
igDeleted: false,
|
||||||
|
fbDeleted: false,
|
||||||
|
clearIg: true,
|
||||||
|
clearFb: true,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
const resolved = await resolveTenantMetaTargets(item.tenant_id);
|
const resolved = await resolveTenantMetaTargets(item.tenant_id);
|
||||||
if (!resolved.targets) {
|
if (!resolved.targets) {
|
||||||
return { ok: false, error: resolved.error, cleared: false };
|
return {
|
||||||
|
ok: false,
|
||||||
|
partialOk: false,
|
||||||
|
error: resolved.error,
|
||||||
|
igDeleted: false,
|
||||||
|
fbDeleted: false,
|
||||||
|
clearIg: false,
|
||||||
|
clearFb: false,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
const { pageToken } = resolved.targets;
|
const { pageToken, pageId } = resolved.targets;
|
||||||
const errors: string[] = [];
|
const errors: string[] = [];
|
||||||
|
let igDeleted = false;
|
||||||
|
let fbDeleted = false;
|
||||||
|
|
||||||
if (igId) {
|
if (igId) {
|
||||||
const d = await graphDelete(encodeURIComponent(igId), pageToken);
|
const d = await graphDelete(encodeURIComponent(igId), pageToken);
|
||||||
if (!d.ok) errors.push(`IG: ${formatGraphError(d.raw)}`);
|
if (d.ok) {
|
||||||
|
igDeleted = true;
|
||||||
|
} else {
|
||||||
|
const msg = formatGraphError(d.raw).toLowerCase();
|
||||||
|
if (
|
||||||
|
msg.includes('does not exist') ||
|
||||||
|
msg.includes('unsupported') ||
|
||||||
|
msg.includes('no existe')
|
||||||
|
) {
|
||||||
|
igDeleted = true; // already gone
|
||||||
|
} else {
|
||||||
|
errors.push(`IG: ${formatGraphError(d.raw)}`);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
igDeleted = true;
|
||||||
|
}
|
||||||
|
|
||||||
if (fbId) {
|
if (fbId) {
|
||||||
const d = await graphDelete(encodeURIComponent(fbId), pageToken);
|
const fb = await deleteFacebookObject(fbId, pageToken, pageId);
|
||||||
if (!d.ok) errors.push(`FB: ${formatGraphError(d.raw)}`);
|
if (fb.ok) {
|
||||||
|
fbDeleted = true;
|
||||||
|
} else {
|
||||||
|
errors.push(`FB: ${fb.error || 'fallo'}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fbDeleted = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ok = igDeleted && fbDeleted;
|
||||||
|
return {
|
||||||
|
ok,
|
||||||
|
partialOk: igDeleted || fbDeleted,
|
||||||
|
error: errors.length ? errors.join(' · ') : undefined,
|
||||||
|
igDeleted,
|
||||||
|
fbDeleted,
|
||||||
|
clearIg: igDeleted,
|
||||||
|
clearFb: fbDeleted,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update live posts with current caption/media:
|
||||||
|
* - Facebook: edit message in place when possible
|
||||||
|
* - Instagram: delete + republish (API no permite editar caption)
|
||||||
|
*/
|
||||||
|
export async function updateLivePipelineItem(
|
||||||
|
item: PipelinePublishRow
|
||||||
|
): Promise<{
|
||||||
|
ok: boolean;
|
||||||
|
error?: string;
|
||||||
|
igMediaId?: string | null;
|
||||||
|
fbPostId?: string | null;
|
||||||
|
igPermalink?: string | null;
|
||||||
|
detail?: string;
|
||||||
|
}> {
|
||||||
|
const channels = normalizeChannels(item.channels);
|
||||||
|
const resolved = await resolveTenantMetaTargets(item.tenant_id);
|
||||||
|
if (!resolved.targets) {
|
||||||
|
return { ok: false, error: resolved.error };
|
||||||
|
}
|
||||||
|
const { pageId, igUserId, pageToken } = resolved.targets;
|
||||||
|
const caption = (item.caption || item.title || '').slice(0, 2200);
|
||||||
|
const notes: string[] = [];
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
let igMediaId: string | null = item.meta_ig_media_id || null;
|
||||||
|
let fbPostId: string | null = item.meta_fb_post_id || null;
|
||||||
|
let igPermalink: string | null = null;
|
||||||
|
|
||||||
|
// Facebook: try in-place caption edit
|
||||||
|
if (channels.includes('facebook') && fbPostId) {
|
||||||
|
const edit = await graphPost(encodeURIComponent(fbPostId), pageToken, {
|
||||||
|
message: caption,
|
||||||
|
});
|
||||||
|
if (edit.ok) {
|
||||||
|
notes.push('FB: texto actualizado');
|
||||||
|
} else {
|
||||||
|
// Fallback: delete + republish FB
|
||||||
|
const del = await deleteFacebookObject(fbPostId, pageToken, pageId);
|
||||||
|
if (!del.ok) {
|
||||||
|
errors.push(`FB edit: ${formatGraphError(edit.raw)}`);
|
||||||
|
} else {
|
||||||
|
const pub = await publishFbPost(pageId, pageToken, {
|
||||||
|
...item,
|
||||||
|
meta_fb_post_id: null,
|
||||||
|
});
|
||||||
|
if (!pub.id) {
|
||||||
|
errors.push(`FB republicar: ${pub.error || 'fallo'}`);
|
||||||
|
fbPostId = null;
|
||||||
|
} else {
|
||||||
|
fbPostId = pub.id;
|
||||||
|
notes.push('FB: republicado (no se pudo editar in-place)');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (channels.includes('facebook') && !fbPostId) {
|
||||||
|
const pub = await publishFbPost(pageId, pageToken, item);
|
||||||
|
if (!pub.id) errors.push(`FB: ${pub.error || 'fallo'}`);
|
||||||
|
else {
|
||||||
|
fbPostId = pub.id;
|
||||||
|
notes.push('FB: publicado');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Instagram: must delete + republish
|
||||||
|
if (channels.includes('instagram')) {
|
||||||
|
if (!igUserId) {
|
||||||
|
errors.push('Sin Instagram Business vinculado');
|
||||||
|
} else {
|
||||||
|
if (igMediaId) {
|
||||||
|
const d = await graphDelete(encodeURIComponent(igMediaId), pageToken);
|
||||||
|
if (
|
||||||
|
!d.ok &&
|
||||||
|
!formatGraphError(d.raw).toLowerCase().includes('does not exist') &&
|
||||||
|
!formatGraphError(d.raw).toLowerCase().includes('unsupported')
|
||||||
|
) {
|
||||||
|
errors.push(`IG borrar: ${formatGraphError(d.raw)}`);
|
||||||
|
}
|
||||||
|
igMediaId = null;
|
||||||
|
}
|
||||||
|
const pub = await publishIgMedia(igUserId, pageToken, {
|
||||||
|
...item,
|
||||||
|
meta_ig_media_id: null,
|
||||||
|
});
|
||||||
|
if (!pub.id) {
|
||||||
|
errors.push(`IG republicar: ${pub.error || 'fallo'}`);
|
||||||
|
} else {
|
||||||
|
igMediaId = pub.id;
|
||||||
|
igPermalink = pub.permalink;
|
||||||
|
notes.push('IG: republicado con nuevo copy');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ok: errors.length === 0,
|
ok: errors.length === 0,
|
||||||
error: errors.length ? errors.join(' · ') : undefined,
|
error: errors.length ? errors.join(' · ') : undefined,
|
||||||
cleared: true,
|
igMediaId,
|
||||||
|
fbPostId,
|
||||||
|
igPermalink,
|
||||||
|
detail: notes.join(' · ') || undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user