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:
4Ventures
2026-10-07 13:46:48 +02:00
parent 26dc411647
commit 2759aa87f6
3 changed files with 431 additions and 55 deletions

View File

@@ -555,7 +555,8 @@ async function publishFbPost(
}
export async function publishPipelineItem(
item: PipelinePublishRow
item: PipelinePublishRow,
opts?: { forceIg?: boolean; forceFb?: boolean; skipIg?: boolean; skipFb?: boolean }
): Promise<PublishResult> {
const channels = normalizeChannels(item.channels);
const resolved = await resolveTenantMetaTargets(item.tenant_id);
@@ -564,13 +565,16 @@ export async function publishPipelineItem(
}
const { pageId, igUserId, pageToken } = resolved.targets;
let igMediaId: string | null = item.meta_ig_media_id || null;
let fbPostId: string | null = item.meta_fb_post_id || null;
let igMediaId: string | null = opts?.forceIg ? null : item.meta_ig_media_id || null;
let fbPostId: string | null = opts?.forceFb ? null : item.meta_fb_post_id || null;
let igPermalink: string | null = null;
let fbPermalink: string | null = null;
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) {
errors.push('Sin Instagram Business vinculado a la Página');
} else if (!igMediaId) {
@@ -584,7 +588,7 @@ export async function publishPipelineItem(
}
}
if (channels.includes('facebook')) {
if (wantFb) {
if (!fbPostId) {
const fb = await publishFbPost(pageId, pageToken, item);
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 fbOk = !wantFb || Boolean(fbPostId);
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: {
tenant_id: string;
meta_ig_media_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 fbId = item.meta_fb_post_id?.trim();
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);
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[] = [];
let igDeleted = false;
let fbDeleted = false;
if (igId) {
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) {
const d = await graphDelete(encodeURIComponent(fbId), pageToken);
if (!d.ok) errors.push(`FB: ${formatGraphError(d.raw)}`);
const fb = await deleteFacebookObject(fbId, pageToken, pageId);
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 {
ok: errors.length === 0,
error: errors.length ? errors.join(' · ') : undefined,
cleared: true,
igMediaId,
fbPostId,
igPermalink,
detail: notes.join(' · ') || undefined,
};
}