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>
949 lines
28 KiB
TypeScript
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;
|
|
}
|