Files
4ventures-client-portal/src/lib/meta-publish.ts
4Ventures 025fc85175 Harden Meta publish against dirty captions and IG duplicates.
Strip production notes before posting, persist each network id immediately, refuse IG republish if delete fails, and place FB photos via feed for better timeline visibility.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-08 17:17:07 +02:00

949 lines
28 KiB
TypeScript

/**
* Publish / delete creative_pipeline items on Instagram + Facebook Page
* via Meta Graph Content Publishing API.
*/
import { resolveMetaAccessTokens } from '@/lib/ads-discovery';
import { serviceHeaders, supabaseUrl } from '@/lib/agency';
import { ASSETS_BUCKET, serviceSupabase } from '@/lib/assets';
import {
normalizeChannels,
type PublishChannel,
} from '@/lib/pipeline-schedule';
import { sanitizePublishCaption } from '@/lib/publish-caption';
const META = 'https://graph.facebook.com/v21.0';
export type PipelinePublishRow = {
id: string;
tenant_id: string;
title: string;
format: string;
caption?: string | null;
cover_path?: string | null;
cover_mime?: string | null;
slide_paths?: unknown;
channels?: unknown;
meta_ig_media_id?: string | null;
meta_fb_post_id?: string | null;
publish_attempts?: number | null;
};
export type MetaTargets = {
pageId: string;
igUserId: string | null;
pageToken: string;
};
export type PublishResult = {
ok: boolean;
igMediaId?: string | null;
fbPostId?: string | null;
igPermalink?: string | null;
fbPermalink?: string | null;
error?: string;
channels: PublishChannel[];
/** Caption actually sent to Meta (production noise stripped). */
sanitizedCaption?: string | null;
captionStripped?: string[];
};
type GraphJson = Record<string, unknown>;
function formatGraphError(raw: string): string {
try {
const j = JSON.parse(raw) as {
error?: {
message?: string;
code?: number;
error_subcode?: number;
error_user_title?: string;
error_user_msg?: string;
};
};
const e = j.error;
if (!e) return raw.slice(0, 280);
if (e.code === 190) {
return (
'Token Meta inválido. Revisa el system user 4V2030 con pages_* + ' +
'instagram_content_publish y asígnalo a la Página del cliente.'
);
}
return (
e.error_user_title ||
e.error_user_msg ||
e.message ||
raw
).slice(0, 400);
} catch {
return raw.slice(0, 280);
}
}
async function graphGet(
path: string,
token: string,
query: Record<string, string> = {}
): Promise<{ ok: boolean; json: GraphJson; raw: string }> {
const q = new URLSearchParams({ ...query, access_token: token });
const r = await fetch(`${META}/${path}?${q}`, { cache: 'no-store' });
const raw = await r.text();
let json: GraphJson = {};
try {
json = JSON.parse(raw) as GraphJson;
} catch {
/* keep empty */
}
return { ok: r.ok, json, raw };
}
async function graphPost(
path: string,
token: string,
body: Record<string, string>
): Promise<{ ok: boolean; json: GraphJson; raw: string }> {
const form = new URLSearchParams({ ...body, access_token: token });
const r = await fetch(`${META}/${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: form.toString(),
cache: 'no-store',
});
const raw = await r.text();
let json: GraphJson = {};
try {
json = JSON.parse(raw) as GraphJson;
} catch {
/* keep empty */
}
return { ok: r.ok, json, raw };
}
async function graphDelete(
path: string,
token: string
): Promise<{ ok: boolean; raw: string }> {
const r = await fetch(
`${META}/${path}?access_token=${encodeURIComponent(token)}`,
{ method: 'DELETE', cache: 'no-store' }
);
return { ok: r.ok, raw: await r.text() };
}
async function resolvePageAccessToken(
userToken: string,
pageId: string
): Promise<string> {
const r = await graphGet(encodeURIComponent(pageId), userToken, {
fields: 'access_token,name',
});
if (r.ok && typeof r.json.access_token === 'string') {
return r.json.access_token;
}
const accounts = await graphGet('me/accounts', userToken, {
fields: 'id,access_token',
limit: '100',
});
if (accounts.ok) {
const data = (accounts.json.data as { id?: string; access_token?: string }[]) || [];
const hit = data.find((p) => p.id === pageId && p.access_token);
if (hit?.access_token) return hit.access_token;
}
return userToken;
}
/** Public HTTPS URL Meta can fetch (signed storage, 2h). */
export async function signedAssetUrl(
path: string,
ttlSec = 7200
): Promise<string | null> {
if (!path?.trim()) return null;
const svc = serviceSupabase();
if (!svc) return null;
const signed = await svc.storage
.from(ASSETS_BUCKET)
.createSignedUrl(path.trim(), ttlSec);
if (signed.error || !signed.data?.signedUrl) return null;
return signed.data.signedUrl;
}
function slidePathsOf(raw: unknown): string[] {
if (!Array.isArray(raw)) return [];
return raw
.map((x) => (typeof x === 'string' ? x.trim() : ''))
.filter(Boolean);
}
function isVideoMime(mime?: string | null, path?: string | null): boolean {
const m = (mime || '').toLowerCase();
if (m.startsWith('video/')) return true;
const p = (path || '').toLowerCase();
return /\.(mp4|mov|m4v|webm)$/.test(p);
}
async function loadIntegrations(tenantId: string): Promise<{
pageId: string | null;
igUserId: string | null;
}> {
const headers = serviceHeaders();
const base = supabaseUrl();
if (!headers || !base) return { pageId: null, igUserId: null };
const r = await fetch(
`${base}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(
tenantId
)}&provider=in.(facebook,instagram)&status=eq.connected&select=provider,external_account_id`,
{ headers, cache: 'no-store' }
);
if (!r.ok) return { pageId: null, igUserId: null };
const rows = (await r.json()) as {
provider: string;
external_account_id?: string | null;
}[];
let pageId: string | null = null;
let igUserId: string | null = null;
for (const row of rows) {
const id = row.external_account_id?.trim() || '';
if (!id || id.startsWith('http')) continue;
if (row.provider === 'facebook') pageId = id;
if (row.provider === 'instagram') igUserId = id;
}
return { pageId, igUserId };
}
/** Block publish if FB Page / IG user id is also stored on another tenant. */
async function assertMetaAccountsExclusive(
tenantId: string,
pageId: string | null,
igUserId: string | null
): Promise<{ ok: boolean; error?: string }> {
const ids = [pageId, igUserId].filter(
(x): x is string => Boolean(x && x.trim())
);
if (!ids.length) return { ok: true };
const headers = serviceHeaders();
const base = supabaseUrl();
if (!headers || !base) return { ok: true };
const or = ids
.map((id) => `external_account_id.eq.${encodeURIComponent(id)}`)
.join(',');
const r = await fetch(
`${base}/rest/v1/integrations?or=(${or})&provider=in.(facebook,instagram)&status=eq.connected&select=tenant_id,provider,external_account_id`,
{ headers, cache: 'no-store' }
);
if (!r.ok) return { ok: true };
const rows = (await r.json()) as {
tenant_id: string;
provider: string;
external_account_id?: string | null;
}[];
const foreign = rows.filter((row) => row.tenant_id !== tenantId);
if (!foreign.length) return { ok: true };
const detail = foreign
.map(
(f) =>
`${f.provider}:${f.external_account_id || '?'}→tenant ${f.tenant_id.slice(0, 8)}`
)
.join(', ');
return {
ok: false,
error: `Cuenta Meta también vinculada a otro cliente (${detail}). Corrige Integraciones antes de publicar.`,
};
}
/** Find Facebook Page that owns an IG business account. */
async function findPageForIg(
token: string,
igUserId: string
): Promise<{ pageId: string; pageToken: string } | null> {
const accounts = await graphGet('me/accounts', token, {
fields: 'id,access_token,instagram_business_account{id}',
limit: '100',
});
if (!accounts.ok) return null;
const data =
(accounts.json.data as {
id?: string;
access_token?: string;
instagram_business_account?: { id?: string };
}[]) || [];
for (const p of data) {
if (p.instagram_business_account?.id === igUserId && p.id && p.access_token) {
return { pageId: p.id, pageToken: p.access_token };
}
}
return null;
}
export async function resolveTenantMetaTargets(
tenantId: string
): Promise<{ targets: MetaTargets | null; error?: string }> {
const tokens = await resolveMetaAccessTokens();
if (!tokens.length) {
return {
targets: null,
error:
'Sin token Meta. Configura META_ACCESS_TOKEN o pégalo en Agencia (system user 4V2030).',
};
}
const { pageId: integPage, igUserId } = await loadIntegrations(tenantId);
if (!integPage && !igUserId) {
return {
targets: null,
error:
'Cliente sin Facebook Page / Instagram conectados en Integraciones.',
};
}
// Refuse if the same Page/IG id is linked to another tenant (cross-client leak).
const exclusive = await assertMetaAccountsExclusive(
tenantId,
integPage,
igUserId
);
if (!exclusive.ok) {
return { targets: null, error: exclusive.error };
}
let pageId = integPage;
let pageToken: string | null = null;
let working: string | null = null;
for (const t of tokens) {
if (pageId) {
const probe = await graphGet(encodeURIComponent(pageId), t, {
fields: 'id,name',
});
if (probe.ok) {
working = t;
pageToken = await resolvePageAccessToken(t, pageId);
break;
}
}
if (igUserId) {
const found = await findPageForIg(t, igUserId);
if (found) {
working = t;
pageId = found.pageId;
pageToken = found.pageToken;
break;
}
}
}
if (!working || !pageId || !pageToken) {
return {
targets: null,
error:
'No se pudo obtener token de Página. Revisa que el system user tenga la Página asignada.',
};
}
let ig = igUserId;
if (!ig) {
const page = await graphGet(encodeURIComponent(pageId), pageToken, {
fields: 'instagram_business_account{id}',
});
const iba = page.json.instagram_business_account as { id?: string } | undefined;
ig = iba?.id || null;
}
return {
targets: {
pageId,
igUserId: ig,
pageToken,
},
};
}
async function waitIgContainer(
creationId: string,
token: string,
maxAttempts = 20
): Promise<{ ok: boolean; error?: string }> {
for (let i = 0; i < maxAttempts; i++) {
const st = await graphGet(encodeURIComponent(creationId), token, {
fields: 'status_code,status',
});
if (!st.ok) {
return { ok: false, error: formatGraphError(st.raw) };
}
const code = String(st.json.status_code || '').toUpperCase();
if (code === 'FINISHED') return { ok: true };
if (code === 'ERROR' || code === 'EXPIRED') {
return {
ok: false,
error: String(st.json.status || `Contenedor IG ${code}`),
};
}
await new Promise((r) => setTimeout(r, 1500));
}
return { ok: false, error: 'Timeout esperando contenedor IG' };
}
function publicCaption(item: PipelinePublishRow, maxLen = 2200): string {
const raw = item.caption || item.title || '';
return sanitizePublishCaption(raw, { maxLen }).caption || (item.title || '').slice(0, maxLen);
}
async function publishIgMedia(
igUserId: string,
pageToken: string,
item: PipelinePublishRow
): Promise<{ id: string | null; permalink: string | null; error?: string }> {
const caption = publicCaption(item, 2200);
const format = (item.format || 'post').toLowerCase();
const slides = slidePathsOf(item.slide_paths);
const cover = item.cover_path || slides[0] || null;
if (!cover) {
return { id: null, permalink: null, error: 'Falta imagen/vídeo (portada)' };
}
const video = isVideoMime(item.cover_mime, cover);
let creationId: string | null = null;
if (format === 'carrusel') {
const paths = [cover, ...slides.filter((p) => p !== cover)].slice(0, 10);
if (paths.length < 2) {
return {
id: null,
permalink: null,
error: 'Carrusel necesita al menos 2 imágenes',
};
}
const childIds: string[] = [];
for (const path of paths) {
const url = await signedAssetUrl(path);
if (!url) {
return { id: null, permalink: null, error: `No URL firmada: ${path}` };
}
const child = await graphPost(`${encodeURIComponent(igUserId)}/media`, pageToken, {
image_url: url,
is_carousel_item: 'true',
});
if (!child.ok || typeof child.json.id !== 'string') {
return { id: null, permalink: null, error: formatGraphError(child.raw) };
}
childIds.push(child.json.id);
}
const parent = await graphPost(`${encodeURIComponent(igUserId)}/media`, pageToken, {
media_type: 'CAROUSEL',
children: childIds.join(','),
caption,
});
if (!parent.ok || typeof parent.json.id !== 'string') {
return { id: null, permalink: null, error: formatGraphError(parent.raw) };
}
creationId = parent.json.id;
} else if (format === 'reel' || (format === 'post' && video)) {
const url = await signedAssetUrl(cover);
if (!url) {
return { id: null, permalink: null, error: 'No URL firmada para vídeo' };
}
const body: Record<string, string> = {
media_type: 'REELS',
video_url: url,
caption,
share_to_feed: 'true',
};
const created = await graphPost(
`${encodeURIComponent(igUserId)}/media`,
pageToken,
body
);
if (!created.ok || typeof created.json.id !== 'string') {
return { id: null, permalink: null, error: formatGraphError(created.raw) };
}
creationId = created.json.id;
} else if (format === 'story') {
const url = await signedAssetUrl(cover);
if (!url) {
return { id: null, permalink: null, error: 'No URL firmada para story' };
}
const body: Record<string, string> = video
? { media_type: 'STORIES', video_url: url }
: { media_type: 'STORIES', image_url: url };
const created = await graphPost(
`${encodeURIComponent(igUserId)}/media`,
pageToken,
body
);
if (!created.ok || typeof created.json.id !== 'string') {
return { id: null, permalink: null, error: formatGraphError(created.raw) };
}
creationId = created.json.id;
} else {
const url = await signedAssetUrl(cover);
if (!url) {
return { id: null, permalink: null, error: 'No URL firmada para imagen' };
}
const created = await graphPost(
`${encodeURIComponent(igUserId)}/media`,
pageToken,
{ image_url: url, caption }
);
if (!created.ok || typeof created.json.id !== 'string') {
return { id: null, permalink: null, error: formatGraphError(created.raw) };
}
creationId = created.json.id;
}
const ready = await waitIgContainer(creationId, pageToken);
if (!ready.ok) {
return { id: null, permalink: null, error: ready.error };
}
const pub = await graphPost(
`${encodeURIComponent(igUserId)}/media_publish`,
pageToken,
{ creation_id: creationId }
);
if (!pub.ok || typeof pub.json.id !== 'string') {
return { id: null, permalink: null, error: formatGraphError(pub.raw) };
}
const mediaId = pub.json.id;
const per = await graphGet(encodeURIComponent(mediaId), pageToken, {
fields: 'permalink',
});
const permalink =
per.ok && typeof per.json.permalink === 'string'
? per.json.permalink
: null;
return { id: mediaId, permalink };
}
async function publishFbFeedWithPhotos(
pageId: string,
pageToken: string,
message: string,
photoIds: string[]
): Promise<{ id: string | null; permalink: string | null; error?: string }> {
if (!photoIds.length) {
return { id: null, permalink: null, error: 'Sin fotos para el feed FB' };
}
const form = new URLSearchParams({
message,
access_token: pageToken,
});
photoIds.forEach((id, i) => {
form.append(`attached_media[${i}]`, JSON.stringify({ media_fbid: id }));
});
const r = await fetch(`${META}/${encodeURIComponent(pageId)}/feed`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: form.toString(),
cache: 'no-store',
});
const raw = await r.text();
let json: GraphJson = {};
try {
json = JSON.parse(raw) as GraphJson;
} catch {
/* */
}
if (!r.ok || typeof json.id !== 'string') {
return { id: null, permalink: null, error: formatGraphError(raw) };
}
return { id: json.id, permalink: null };
}
async function publishFbPost(
pageId: string,
pageToken: string,
item: PipelinePublishRow
): Promise<{ id: string | null; permalink: string | null; error?: string }> {
// Prefer /feed + unpublished photos so the post lands on the Page timeline
// (direct /photos published=true often only shows in Fotos, not on top).
const message = publicCaption(item, 6000);
const format = (item.format || 'post').toLowerCase();
const slides = slidePathsOf(item.slide_paths);
const cover = item.cover_path || slides[0] || null;
const video = isVideoMime(item.cover_mime, cover);
if (!cover) {
const feed = await graphPost(`${encodeURIComponent(pageId)}/feed`, pageToken, {
message,
});
if (!feed.ok || typeof feed.json.id !== 'string') {
return { id: null, permalink: null, error: formatGraphError(feed.raw) };
}
return { id: feed.json.id, permalink: null };
}
if (format === 'reel' || video) {
const url = await signedAssetUrl(cover);
if (!url) {
return { id: null, permalink: null, error: 'No URL firmada FB vídeo' };
}
const vid = await graphPost(`${encodeURIComponent(pageId)}/videos`, pageToken, {
file_url: url,
description: message,
published: 'true',
});
if (!vid.ok || typeof vid.json.id !== 'string') {
return { id: null, permalink: null, error: formatGraphError(vid.raw) };
}
return { id: vid.json.id, permalink: null };
}
const paths =
format === 'carrusel'
? [cover, ...slides.filter((p) => p !== cover)].slice(0, 10)
: [cover];
const mediaIds: string[] = [];
for (const path of paths) {
const url = await signedAssetUrl(path);
if (!url) continue;
const ph = await graphPost(`${encodeURIComponent(pageId)}/photos`, pageToken, {
url,
published: 'false',
});
if (ph.ok && typeof ph.json.id === 'string') {
mediaIds.push(ph.json.id);
}
}
if (format === 'carrusel' && mediaIds.length < 2) {
return {
id: null,
permalink: null,
error: 'Carrusel FB necesita al menos 2 imágenes',
};
}
if (!mediaIds.length) {
return { id: null, permalink: null, error: 'No URL firmada FB imagen' };
}
return publishFbFeedWithPhotos(pageId, pageToken, message, mediaIds);
}
export async function publishPipelineItem(
item: PipelinePublishRow,
opts?: { forceIg?: boolean; forceFb?: boolean; skipIg?: boolean; skipFb?: boolean }
): Promise<PublishResult> {
const channels = normalizeChannels(item.channels);
const scrub = sanitizePublishCaption(item.caption || item.title || '', {
maxLen: 6000,
});
const cleanItem: PipelinePublishRow = {
...item,
caption: scrub.caption || item.title || '',
};
const resolved = await resolveTenantMetaTargets(item.tenant_id);
if (!resolved.targets) {
return {
ok: false,
error: resolved.error,
channels,
sanitizedCaption: scrub.caption,
captionStripped: scrub.stripped,
};
}
const { pageId, igUserId, pageToken } = resolved.targets;
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[] = [];
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) {
const ig = await publishIgMedia(igUserId, pageToken, cleanItem);
if (ig.error || !ig.id) {
errors.push(`IG: ${ig.error || 'fallo'}`);
} else {
igMediaId = ig.id;
igPermalink = ig.permalink;
}
}
}
if (wantFb) {
if (!fbPostId) {
const fb = await publishFbPost(pageId, pageToken, cleanItem);
if (fb.error || !fb.id) {
errors.push(`FB: ${fb.error || 'fallo'}`);
} else {
fbPostId = fb.id;
fbPermalink = fb.permalink;
}
}
}
const igOk = !wantIg || Boolean(igMediaId);
const fbOk = !wantFb || Boolean(fbPostId);
const ok = igOk && fbOk && errors.length === 0;
return {
ok,
igMediaId,
fbPostId,
igPermalink,
fbPermalink,
error: errors.length ? errors.join(' · ') : undefined,
channels,
sanitizedCaption: scrub.caption,
captionStripped: scrub.stripped,
};
}
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<DeleteMetaResult> {
const igId = item.meta_ig_media_id?.trim();
const fbId = item.meta_fb_post_id?.trim();
if (!igId && !fbId) {
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,
partialOk: false,
error: resolved.error,
igDeleted: false,
fbDeleted: false,
clearIg: false,
clearFb: false,
};
}
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) {
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 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 scrub = sanitizePublishCaption(item.caption || item.title || '', {
maxLen: 2200,
});
const caption = scrub.caption || (item.title || '').slice(0, 2200);
const cleanItem: PipelinePublishRow = { ...item, caption };
const notes: string[] = [];
const errors: string[] = [];
if (scrub.stripped.length) {
notes.push(`Copy: quitadas ${scrub.stripped.length} notas internas`);
}
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, {
...cleanItem,
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, cleanItem);
if (!pub.id) errors.push(`FB: ${pub.error || 'fallo'}`);
else {
fbPostId = pub.id;
notes.push('FB: publicado');
}
}
// Instagram: must delete + republish — never publish a second copy if delete fails.
if (channels.includes('instagram')) {
if (!igUserId) {
errors.push('Sin Instagram Business vinculado');
} else {
let canRepublish = true;
if (igMediaId) {
const d = await graphDelete(encodeURIComponent(igMediaId), pageToken);
const gone =
d.ok ||
formatGraphError(d.raw).toLowerCase().includes('does not exist') ||
formatGraphError(d.raw).toLowerCase().includes('unsupported') ||
formatGraphError(d.raw).toLowerCase().includes('no existe');
if (!gone) {
errors.push(
`IG borrar: ${formatGraphError(d.raw)} (no se republica para evitar duplicado)`
);
canRepublish = false;
} else {
igMediaId = null;
}
}
if (canRepublish) {
const pub = await publishIgMedia(igUserId, pageToken, {
...cleanItem,
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,
igMediaId,
fbPostId,
igPermalink,
detail: notes.join(' · ') || undefined,
};
}
export function mergePermalinkNotes(
notes: string | null | undefined,
igPermalink?: string | null,
fbPermalink?: string | null
): string | null {
const parts: string[] = [];
const base = (notes || '').trim();
if (base && !/instagram\.com/i.test(base)) parts.push(base);
else if (base) parts.push(base.replace(/https?:\/\/(?:www\.)?instagram\.com\S+/gi, '').trim());
if (igPermalink) parts.push(igPermalink);
if (fbPermalink) parts.push(`Facebook: ${fbPermalink}`);
const out = parts.filter(Boolean).join('\n').trim();
return out || null;
}