Wire Meta IG/FB publish from pipeline with feed view and delete.

Agency can publish now or on schedule, remove live posts from Meta, and browse pieces newest-first like an IG board.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
4Ventures
2026-10-06 18:18:15 +02:00
parent c2bbbfcd06
commit ade2762b01
4 changed files with 1197 additions and 67 deletions

662
src/lib/meta-publish.ts Normal file
View File

@@ -0,0 +1,662 @@
/**
* 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';
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[];
};
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 };
}
/** 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.',
};
}
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' };
}
async function publishIgMedia(
igUserId: string,
pageToken: string,
item: PipelinePublishRow
): Promise<{ id: string | null; permalink: string | null; error?: string }> {
const caption = (item.caption || item.title || '').slice(0, 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 publishFbPost(
pageId: string,
pageToken: string,
item: PipelinePublishRow
): Promise<{ id: string | null; permalink: string | null; error?: string }> {
const message = (item.caption || item.title || '').slice(0, 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 };
}
if (format === 'carrusel') {
const paths = [cover, ...slides.filter((p) => p !== cover)].slice(0, 10);
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 (mediaIds.length >= 2) {
const form = new URLSearchParams({
message,
access_token: pageToken,
});
mediaIds.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 };
}
}
const url = await signedAssetUrl(cover);
if (!url) {
return { id: null, permalink: null, error: 'No URL firmada FB imagen' };
}
const photo = await graphPost(`${encodeURIComponent(pageId)}/photos`, pageToken, {
url,
caption: message,
published: 'true',
});
if (
!photo.ok ||
(typeof photo.json.id !== 'string' && typeof photo.json.post_id !== 'string')
) {
return { id: null, permalink: null, error: formatGraphError(photo.raw) };
}
const id =
(typeof photo.json.post_id === 'string' && photo.json.post_id) ||
(typeof photo.json.id === 'string' && photo.json.id) ||
null;
return { id, permalink: null };
}
export async function publishPipelineItem(
item: PipelinePublishRow
): Promise<PublishResult> {
const channels = normalizeChannels(item.channels);
const resolved = await resolveTenantMetaTargets(item.tenant_id);
if (!resolved.targets) {
return { ok: false, error: resolved.error, channels };
}
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 igPermalink: string | null = null;
let fbPermalink: string | null = null;
const errors: string[] = [];
if (channels.includes('instagram')) {
if (!igUserId) {
errors.push('Sin Instagram Business vinculado a la Página');
} else if (!igMediaId) {
const ig = await publishIgMedia(igUserId, pageToken, item);
if (ig.error || !ig.id) {
errors.push(`IG: ${ig.error || 'fallo'}`);
} else {
igMediaId = ig.id;
igPermalink = ig.permalink;
}
}
}
if (channels.includes('facebook')) {
if (!fbPostId) {
const fb = await publishFbPost(pageId, pageToken, item);
if (fb.error || !fb.id) {
errors.push(`FB: ${fb.error || 'fallo'}`);
} else {
fbPostId = fb.id;
fbPermalink = fb.permalink;
}
}
}
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;
return {
ok,
igMediaId,
fbPostId,
igPermalink,
fbPermalink,
error: errors.length ? errors.join(' · ') : undefined,
channels,
};
}
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 }> {
const igId = item.meta_ig_media_id?.trim();
const fbId = item.meta_fb_post_id?.trim();
if (!igId && !fbId) {
return { ok: true, cleared: false };
}
const resolved = await resolveTenantMetaTargets(item.tenant_id);
if (!resolved.targets) {
return { ok: false, error: resolved.error, cleared: false };
}
const { pageToken } = resolved.targets;
const errors: string[] = [];
if (igId) {
const d = await graphDelete(encodeURIComponent(igId), pageToken);
if (!d.ok) errors.push(`IG: ${formatGraphError(d.raw)}`);
}
if (fbId) {
const d = await graphDelete(encodeURIComponent(fbId), pageToken);
if (!d.ok) errors.push(`FB: ${formatGraphError(d.raw)}`);
}
return {
ok: errors.length === 0,
error: errors.length ? errors.join(' · ') : undefined,
cleared: true,
};
}
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;
}