diff --git a/src/app/api/creative-pipeline/publish/route.ts b/src/app/api/creative-pipeline/publish/route.ts new file mode 100644 index 0000000..3b05ba8 --- /dev/null +++ b/src/app/api/creative-pipeline/publish/route.ts @@ -0,0 +1,190 @@ +/** + * Agency: publish now / delete on Meta (IG+FB) for a pipeline item. + * + * POST { id, action: 'publish_now' | 'delete_meta' } + * 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 { createClient } from '@/lib/supabase/server'; +import { + isAgencyRole, + serviceHeaders, + supabaseUrl, + userRole, +} from '@/lib/agency'; +import { resolveActiveTenant } from '@/lib/tenant-context'; +import { + deleteMetaPosts, + mergePermalinkNotes, + publishPipelineItem, + type PipelinePublishRow, +} from '@/lib/meta-publish'; + +export const dynamic = 'force-dynamic'; +export const maxDuration = 120; + +const SELECT = + 'id,tenant_id,title,format,caption,cover_path,cover_mime,slide_paths,channels,status,notes,publish_status,publish_attempts,meta_ig_media_id,meta_fb_post_id,scheduled_at'; + +async function loadItem( + id: string, + tenantId: string, + headers: Record +): Promise { + const r = await fetch( + `${supabaseUrl()}/rest/v1/creative_pipeline?id=eq.${encodeURIComponent( + id + )}&tenant_id=eq.${encodeURIComponent(tenantId)}&select=${SELECT}&limit=1`, + { headers, cache: 'no-store' } + ); + if (!r.ok) return null; + const rows = (await r.json()) as (PipelinePublishRow & { + notes?: string | null; + status?: string; + })[]; + return rows[0] || null; +} + +async function patchItem( + id: string, + headers: Record, + body: Record +) { + return fetch( + `${supabaseUrl()}/rest/v1/creative_pipeline?id=eq.${encodeURIComponent(id)}`, + { + method: 'PATCH', + headers: { + ...headers, + 'Content-Type': 'application/json', + Prefer: 'return=representation', + }, + body: JSON.stringify({ ...body, updated_at: new Date().toISOString() }), + } + ); +} + +export async function POST(req: NextRequest) { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + if (!isAgencyRole(userRole(user))) { + return NextResponse.json({ error: 'Solo agencia' }, { status: 403 }); + } + const ctx = await resolveActiveTenant(user); + if (!ctx.tenantId || ctx.portfolioWide) { + return NextResponse.json( + { error: 'Selecciona un cliente en Contexto.' }, + { status: 400 } + ); + } + const headers = serviceHeaders(); + if (!headers) { + return NextResponse.json({ error: 'Service role required' }, { status: 503 }); + } + + const body = (await req.json().catch(() => ({}))) as { + id?: string; + action?: string; + }; + const id = typeof body.id === 'string' ? body.id.trim() : ''; + const action = body.action === 'delete_meta' ? 'delete_meta' : 'publish_now'; + if (!id) { + return NextResponse.json({ error: 'id requerido' }, { status: 400 }); + } + + const item = await loadItem(id, ctx.tenantId, headers); + if (!item) { + return NextResponse.json({ error: 'Pieza no encontrada' }, { status: 404 }); + } + + if (action === 'delete_meta') { + const del = await deleteMetaPosts(item); + if (!del.ok) { + return NextResponse.json( + { error: del.error || 'No se pudo borrar en Meta' }, + { status: 502 } + ); + } + const patch = await patchItem(id, headers, { + meta_ig_media_id: null, + meta_fb_post_id: null, + publish_status: 'idle', + publish_error: null, + published_at: null, + }); + const rows = patch.ok ? await patch.json() : null; + return NextResponse.json({ + ok: true, + action: 'delete_meta', + cleared: del.cleared, + item: Array.isArray(rows) ? rows[0] : null, + }); + } + + // publish_now + if (!item.cover_path) { + return NextResponse.json( + { error: 'Sube una portada antes de publicar' }, + { status: 400 } + ); + } + + const attempts = (item.publish_attempts || 0) + 1; + await patchItem(id, headers, { + publish_status: 'publishing', + publish_attempts: attempts, + publish_error: null, + scheduled_at: new Date().toISOString(), + status: item.status === 'aprobado' || item.status === 'publicado' + ? item.status + : 'aprobado', + }); + + const result = await publishPipelineItem(item); + if (!result.ok) { + const fail = await patchItem(id, headers, { + publish_status: 'failed', + publish_error: result.error || 'Error al publicar', + meta_ig_media_id: result.igMediaId || item.meta_ig_media_id || null, + meta_fb_post_id: result.fbPostId || item.meta_fb_post_id || null, + }); + const rows = fail.ok ? await fail.json() : null; + return NextResponse.json( + { + error: result.error || 'Error al publicar', + item: Array.isArray(rows) ? rows[0] : null, + }, + { status: 502 } + ); + } + + const notes = mergePermalinkNotes( + (item as { notes?: string | null }).notes, + result.igPermalink, + result.fbPermalink + ); + 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({ + ok: true, + action: 'publish_now', + igMediaId: result.igMediaId, + fbPostId: result.fbPostId, + igPermalink: result.igPermalink, + item: Array.isArray(rows) ? rows[0] : null, + }); +} diff --git a/src/app/api/cron/pipeline-publish/route.ts b/src/app/api/cron/pipeline-publish/route.ts index 8858227..3e33662 100644 --- a/src/app/api/cron/pipeline-publish/route.ts +++ b/src/app/api/cron/pipeline-publish/route.ts @@ -1,7 +1,6 @@ /** * Cron: pick creative_pipeline rows that are aprobado + scheduled_at due, - * and (for now) mark them queued / report due set. - * Meta IG+FB publish will plug into processDueItem(). + * publish to Meta IG+FB via Content Publishing API. * * Auth: Authorization: Bearer $CRON_SECRET * curl -sS -H "Authorization: Bearer $CRON_SECRET" \ @@ -9,12 +8,18 @@ */ import { NextRequest, NextResponse } from 'next/server'; import { serviceHeaders, supabaseUrl } from '@/lib/agency'; +import { + mergePermalinkNotes, + publishPipelineItem, + type PipelinePublishRow, +} from '@/lib/meta-publish'; export const dynamic = 'force-dynamic'; export const runtime = 'nodejs'; +export const maxDuration = 120; const SELECT = - 'id,tenant_id,title,format,status,scheduled_at,channels,publish_status,publish_attempts,cover_path,caption'; + 'id,tenant_id,title,format,status,scheduled_at,channels,publish_status,publish_attempts,cover_path,cover_mime,slide_paths,caption,notes,meta_ig_media_id,meta_fb_post_id'; function authorize(req: NextRequest): boolean { const secret = process.env.CRON_SECRET?.trim(); @@ -25,6 +30,23 @@ function authorize(req: NextRequest): boolean { return q === secret; } +async function patchItem( + base: string, + headers: Record, + id: string, + body: Record +) { + return fetch(`${base}/rest/v1/creative_pipeline?id=eq.${id}`, { + method: 'PATCH', + headers: { + ...headers, + 'Content-Type': 'application/json', + Prefer: 'return=minimal', + }, + body: JSON.stringify({ ...body, updated_at: new Date().toISOString() }), + }); +} + export async function GET(req: NextRequest) { if (!authorize(req)) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); @@ -39,7 +61,7 @@ export async function GET(req: NextRequest) { const now = new Date().toISOString(); const limit = Math.min( 50, - Math.max(1, Number(req.nextUrl.searchParams.get('limit') || 20)) + Math.max(1, Number(req.nextUrl.searchParams.get('limit') || 10)) ); const dryRun = req.nextUrl.searchParams.get('dry') === '1'; @@ -56,15 +78,11 @@ export async function GET(req: NextRequest) { { status: 500 } ); } - const due = (await list.json()) as { - id: string; - tenant_id: string; + const due = (await list.json()) as (PipelinePublishRow & { title: string; - scheduled_at: string; - channels: string[]; - publish_attempts: number; publish_status: string; - }[]; + notes?: string | null; + })[]; if (dryRun) { return NextResponse.json({ @@ -74,7 +92,7 @@ export async function GET(req: NextRequest) { items: due.map((d) => ({ id: d.id, title: d.title, - scheduled_at: d.scheduled_at, + scheduled_at: (d as { scheduled_at?: string }).scheduled_at, channels: d.channels, attempts: d.publish_attempts, })), @@ -89,48 +107,73 @@ export async function GET(req: NextRequest) { }[] = []; for (const item of due) { - if (item.publish_status === 'queued') { + if (!item.cover_path) { + await patchItem(base, headers, item.id, { + publish_status: 'failed', + publish_error: 'Sin portada — no se puede publicar', + publish_attempts: (item.publish_attempts || 0) + 1, + }); results.push({ id: item.id, title: item.title, - ok: true, - detail: 'already queued — waiting Meta publisher', + ok: false, + detail: 'missing cover', }); continue; } - const attempts = item.publish_attempts || 0; - const lock = await fetch( - `${base}/rest/v1/creative_pipeline?id=eq.${item.id}`, - { - method: 'PATCH', - headers: { - ...headers, - 'Content-Type': 'application/json', - Prefer: 'return=minimal', - }, - body: JSON.stringify({ - publish_status: 'queued', - publish_attempts: attempts, - publish_error: - 'En cola. Publicación Meta (IG/FB) se conectará en el siguiente paso.', - updated_at: new Date().toISOString(), - }), - } - ); + + const attempts = (item.publish_attempts || 0) + 1; + const lock = await patchItem(base, headers, item.id, { + publish_status: 'publishing', + publish_attempts: attempts, + publish_error: null, + }); if (!lock.ok) { results.push({ id: item.id, title: item.title, ok: false, - detail: 'queue mark failed', + detail: 'lock failed', }); continue; } + + const result = await publishPipelineItem(item); + if (!result.ok) { + await patchItem(base, headers, item.id, { + publish_status: 'failed', + publish_error: result.error || 'Error Meta', + meta_ig_media_id: result.igMediaId || item.meta_ig_media_id || null, + meta_fb_post_id: result.fbPostId || item.meta_fb_post_id || null, + }); + results.push({ + id: item.id, + title: item.title, + ok: false, + detail: result.error || 'publish failed', + }); + continue; + } + + const notes = mergePermalinkNotes( + item.notes, + result.igPermalink, + result.fbPermalink + ); + await patchItem(base, headers, item.id, { + 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, + }); results.push({ id: item.id, title: item.title, ok: true, - detail: 'queued for Meta publish', + detail: `published ig=${result.igMediaId || '-'} fb=${result.fbPostId || '-'}`, }); } @@ -138,6 +181,5 @@ export async function GET(req: NextRequest) { ok: true, processed: results.length, results, - meta: 'queue-only — Meta IG/FB publisher not wired yet', }); } diff --git a/src/components/CreativePipelineBoard.tsx b/src/components/CreativePipelineBoard.tsx index 427f366..1617dc3 100644 --- a/src/components/CreativePipelineBoard.tsx +++ b/src/components/CreativePipelineBoard.tsx @@ -14,6 +14,8 @@ import { ChevronLeft, ChevronRight, Filter, + LayoutGrid, + Send, } from 'lucide-react'; import { PageHeader, Surface } from '@/components/ui'; import { @@ -66,6 +68,9 @@ type PipelineItem = { publish_status?: string | null; publish_error?: string | null; publish_attempts?: number | null; + published_at?: string | null; + meta_ig_media_id?: string | null; + meta_fb_post_id?: string | null; calendar_piece_id?: string | null; }; @@ -151,6 +156,7 @@ export default function CreativePipelineBoard() { const [selectedMonth, setSelectedMonth] = useState('all'); const [selectedFormat, setSelectedFormat] = useState('all'); const [previewItem, setPreviewItem] = useState(null); + const [viewMode, setViewMode] = useState<'produccion' | 'tablon'>('produccion'); async function load() { setLoading(true); @@ -260,6 +266,19 @@ export default function CreativePipelineBoard() { return Array.from(map.entries()).sort(([a], [b]) => a.localeCompare(b)); }, [filteredItems]); + /** Tablón tipo IG: más reciente → más antiguo */ + const feedItems = useMemo(() => { + const score = (it: PipelineItem) => { + const t = + Date.parse(it.published_at || '') || + Date.parse(it.scheduled_at || '') || + Date.parse(it.created_at || '') || + 0; + return t; + }; + return [...filteredItems].sort((a, b) => score(b) - score(a)); + }, [filteredItems]); + async function createItem(e: React.FormEvent) { e.preventDefault(); if (!newTitle.trim()) return; @@ -378,10 +397,29 @@ export default function CreativePipelineBoard() { } } - async function removeItem(id: string) { + async function removeItem(id: string, alsoMeta = false) { setBusyId(id); + setErr(null); try { - await fetch(`/api/creative-pipeline?id=${encodeURIComponent(id)}`, { method: 'DELETE' }); + const target = items.find((x) => x.id === id); + if ( + alsoMeta && + (target?.meta_ig_media_id || target?.meta_fb_post_id) + ) { + const del = await fetch('/api/creative-pipeline/publish', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id, action: 'delete_meta' }), + }); + const delData = await del.json(); + if (!del.ok) { + setErr(delData.error || 'No se pudo borrar en Meta'); + return; + } + } + await fetch(`/api/creative-pipeline?id=${encodeURIComponent(id)}`, { + method: 'DELETE', + }); if (previewItem?.id === id) setPreviewItem(null); await load(); } finally { @@ -389,6 +427,65 @@ export default function CreativePipelineBoard() { } } + async function publishNow(id: string) { + if (!isAgency) return; + setBusyId(id); + setErr(null); + try { + const res = await fetch('/api/creative-pipeline/publish', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id, action: 'publish_now' }), + }); + const data = await res.json(); + if (!res.ok) { + setErr(data.error || 'Error al publicar en Meta'); + if (data.item && previewItem?.id === id) { + setPreviewItem({ ...previewItem, ...data.item }); + } + await load(); + return; + } + if (data.item && previewItem?.id === id) { + setPreviewItem({ ...previewItem, ...data.item }); + } + await load(); + } finally { + setBusyId(null); + } + } + + async function deleteOnMeta(id: string) { + if (!isAgency) return; + if ( + !window.confirm( + '¿Borrar esta publicación en Instagram/Facebook? La pieza seguirá en el pipeline.' + ) + ) { + return; + } + setBusyId(id); + setErr(null); + try { + const res = await fetch('/api/creative-pipeline/publish', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id, action: 'delete_meta' }), + }); + const data = await res.json(); + if (!res.ok) { + setErr(data.error || 'No se pudo borrar en Meta'); + return; + } + if (data.item && previewItem?.id === id) { + setPreviewItem({ ...previewItem, ...data.item }); + } + await load(); + } finally { + setBusyId(null); + } + } + async function uploadImage( itemId: string, file: File, @@ -422,7 +519,7 @@ export default function CreativePipelineBoard() { title="Panel de Creatividades" description={ isAgency - ? 'Producción editorial. Programa IG/FB solo desde aquí (vista agencia).' + ? 'Producción editorial. Publica o programa IG/FB desde aquí (vista agencia).' : 'Revisa y aprueba las creatividades propuestas por 4Ventures.' } actions={ @@ -451,10 +548,31 @@ export default function CreativePipelineBoard() { {/* Barra de Filtros por Mes y Formato */}
-
- - Meses ({items.length} piezas): - +
+ +
@@ -517,6 +635,12 @@ export default function CreativePipelineBoard() { ); })}
+ {viewMode === 'tablon' ? ( +

+ Orden: más reciente → más antiguo (fecha de publicación, programación o + creación). Simula el feed de Instagram. +

+ ) : null}
{showForm && isAgency ? ( @@ -695,6 +819,30 @@ export default function CreativePipelineBoard() { No hay piezas con los filtros seleccionados (Mes: {selectedMonth}, Formato: {selectedFormat}).

+ ) : viewMode === 'tablon' ? ( +
+ {feedItems.map((it) => ( + setStatus(it.id, s)} + onDelete={() => { + const hasMeta = Boolean(it.meta_ig_media_id || it.meta_fb_post_id); + const alsoMeta = + hasMeta && + window.confirm( + 'Esta pieza está en redes. ¿Borrar también el post en Instagram/Facebook?' + ); + void removeItem(it.id, alsoMeta); + }} + onUpload={(file, slot, index) => uploadImage(it.id, file, slot, index)} + onPreview={() => setPreviewItem(it)} + /> + ))} +
) : (
{grouped.map(([month, monthItems]) => ( @@ -715,8 +863,20 @@ export default function CreativePipelineBoard() { isAgency={isAgency} busy={busyId === it.id} onStatus={(s) => setStatus(it.id, s)} - onDelete={() => removeItem(it.id)} - onUpload={(file, slot, index) => uploadImage(it.id, file, slot, index)} + onDelete={() => { + const hasMeta = Boolean( + it.meta_ig_media_id || it.meta_fb_post_id + ); + const alsoMeta = + hasMeta && + window.confirm( + 'Esta pieza está en redes. ¿Borrar también el post en Instagram/Facebook?' + ); + void removeItem(it.id, alsoMeta); + }} + onUpload={(file, slot, index) => + uploadImage(it.id, file, slot, index) + } onPreview={() => setPreviewItem(it)} /> ))} @@ -735,6 +895,8 @@ export default function CreativePipelineBoard() { onClose={() => setPreviewItem(null)} onStatus={(s) => setStatus(previewItem.id, s)} onSaveSchedule={(payload) => saveSchedule(previewItem.id, payload)} + onPublishNow={() => publishNow(previewItem.id)} + onDeleteMeta={() => deleteOnMeta(previewItem.id)} /> )}
@@ -745,6 +907,7 @@ function PieceCard({ item, isAgency, busy, + feedStyle = false, onStatus, onDelete, onUpload, @@ -753,6 +916,7 @@ function PieceCard({ item: PipelineItem; isAgency: boolean; busy: boolean; + feedStyle?: boolean; onStatus: (status: string) => void; onDelete: () => void; onUpload: (file: File, slot: 'cover' | 'slide', index: number) => void; @@ -768,11 +932,48 @@ function PieceCard({ const igUrl = getInstagramUrl(item.notes); const clientCanDecide = !isAgency && item.status === 'revision_interna'; + const when = + item.published_at || item.scheduled_at || item.created_at; return ( - + + {feedStyle ? ( +
+
+

+ {item.title} +

+

+ {when + ? new Date(when).toLocaleString('es-ES', { + day: 'numeric', + month: 'short', + hour: '2-digit', + minute: '2-digit', + }) + : '—'} + {item.publish_status === 'published' ? ' · en redes' : ''} +

+
+ + {formatInfo.label} + +
+ ) : null} {/* Portada / Imagen */} -
+
{cover ? ( // eslint-disable-next-line @next/next/no-img-element void; + onPublishNow: () => void; + onDeleteMeta: () => void; }) { const slides = item.slide_paths || []; const allImages = [item.cover_path, ...slides].filter(Boolean) as string[]; @@ -1224,36 +1429,67 @@ function DetailModal({ {item.publish_error}

) : null} -
+
- {schedLocal ? ( + {(item.meta_ig_media_id || item.meta_fb_post_id) && ( - ) : null} + )} +
+ + {schedLocal ? ( + + ) : null} +

- Requiere estado aprobado para entrar en cola. - Publica IG+FB al unísono vía cron (Meta se conecta en el - siguiente paso). + Publicar ahora sale al momento. Con fecha futura + + estado aprobado, el cron publica IG+FB al unísono. + Si ya está en redes y está mal: bórralo aquí y vuelve a publicar.

) : null} diff --git a/src/lib/meta-publish.ts b/src/lib/meta-publish.ts new file mode 100644 index 0000000..cdab1c8 --- /dev/null +++ b/src/lib/meta-publish.ts @@ -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; + +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 = {} +): 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 +): 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 { + 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 { + 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 = { + 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 = 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 { + 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; +}