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:
190
src/app/api/creative-pipeline/publish/route.ts
Normal file
190
src/app/api/creative-pipeline/publish/route.ts
Normal file
@@ -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<string, string>
|
||||||
|
): Promise<PipelinePublishRow & { notes?: string | null; status?: string } | null> {
|
||||||
|
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<string, string>,
|
||||||
|
body: Record<string, unknown>
|
||||||
|
) {
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* Cron: pick creative_pipeline rows that are aprobado + scheduled_at due,
|
* Cron: pick creative_pipeline rows that are aprobado + scheduled_at due,
|
||||||
* and (for now) mark them queued / report due set.
|
* publish to Meta IG+FB via Content Publishing API.
|
||||||
* Meta IG+FB publish will plug into processDueItem().
|
|
||||||
*
|
*
|
||||||
* Auth: Authorization: Bearer $CRON_SECRET
|
* Auth: Authorization: Bearer $CRON_SECRET
|
||||||
* curl -sS -H "Authorization: Bearer $CRON_SECRET" \
|
* curl -sS -H "Authorization: Bearer $CRON_SECRET" \
|
||||||
@@ -9,12 +8,18 @@
|
|||||||
*/
|
*/
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { serviceHeaders, supabaseUrl } from '@/lib/agency';
|
import { serviceHeaders, supabaseUrl } from '@/lib/agency';
|
||||||
|
import {
|
||||||
|
mergePermalinkNotes,
|
||||||
|
publishPipelineItem,
|
||||||
|
type PipelinePublishRow,
|
||||||
|
} from '@/lib/meta-publish';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
|
export const maxDuration = 120;
|
||||||
|
|
||||||
const SELECT =
|
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 {
|
function authorize(req: NextRequest): boolean {
|
||||||
const secret = process.env.CRON_SECRET?.trim();
|
const secret = process.env.CRON_SECRET?.trim();
|
||||||
@@ -25,6 +30,23 @@ function authorize(req: NextRequest): boolean {
|
|||||||
return q === secret;
|
return q === secret;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function patchItem(
|
||||||
|
base: string,
|
||||||
|
headers: Record<string, string>,
|
||||||
|
id: string,
|
||||||
|
body: Record<string, unknown>
|
||||||
|
) {
|
||||||
|
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) {
|
export async function GET(req: NextRequest) {
|
||||||
if (!authorize(req)) {
|
if (!authorize(req)) {
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
@@ -39,7 +61,7 @@ export async function GET(req: NextRequest) {
|
|||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const limit = Math.min(
|
const limit = Math.min(
|
||||||
50,
|
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';
|
const dryRun = req.nextUrl.searchParams.get('dry') === '1';
|
||||||
|
|
||||||
@@ -56,15 +78,11 @@ export async function GET(req: NextRequest) {
|
|||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const due = (await list.json()) as {
|
const due = (await list.json()) as (PipelinePublishRow & {
|
||||||
id: string;
|
|
||||||
tenant_id: string;
|
|
||||||
title: string;
|
title: string;
|
||||||
scheduled_at: string;
|
|
||||||
channels: string[];
|
|
||||||
publish_attempts: number;
|
|
||||||
publish_status: string;
|
publish_status: string;
|
||||||
}[];
|
notes?: string | null;
|
||||||
|
})[];
|
||||||
|
|
||||||
if (dryRun) {
|
if (dryRun) {
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
@@ -74,7 +92,7 @@ export async function GET(req: NextRequest) {
|
|||||||
items: due.map((d) => ({
|
items: due.map((d) => ({
|
||||||
id: d.id,
|
id: d.id,
|
||||||
title: d.title,
|
title: d.title,
|
||||||
scheduled_at: d.scheduled_at,
|
scheduled_at: (d as { scheduled_at?: string }).scheduled_at,
|
||||||
channels: d.channels,
|
channels: d.channels,
|
||||||
attempts: d.publish_attempts,
|
attempts: d.publish_attempts,
|
||||||
})),
|
})),
|
||||||
@@ -89,48 +107,73 @@ export async function GET(req: NextRequest) {
|
|||||||
}[] = [];
|
}[] = [];
|
||||||
|
|
||||||
for (const item of due) {
|
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({
|
results.push({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
title: item.title,
|
title: item.title,
|
||||||
ok: true,
|
ok: false,
|
||||||
detail: 'already queued — waiting Meta publisher',
|
detail: 'missing cover',
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const attempts = item.publish_attempts || 0;
|
|
||||||
const lock = await fetch(
|
const attempts = (item.publish_attempts || 0) + 1;
|
||||||
`${base}/rest/v1/creative_pipeline?id=eq.${item.id}`,
|
const lock = await patchItem(base, headers, item.id, {
|
||||||
{
|
publish_status: 'publishing',
|
||||||
method: 'PATCH',
|
publish_attempts: attempts,
|
||||||
headers: {
|
publish_error: null,
|
||||||
...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(),
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
if (!lock.ok) {
|
if (!lock.ok) {
|
||||||
results.push({
|
results.push({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
title: item.title,
|
title: item.title,
|
||||||
ok: false,
|
ok: false,
|
||||||
detail: 'queue mark failed',
|
detail: 'lock failed',
|
||||||
});
|
});
|
||||||
continue;
|
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({
|
results.push({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
title: item.title,
|
title: item.title,
|
||||||
ok: true,
|
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,
|
ok: true,
|
||||||
processed: results.length,
|
processed: results.length,
|
||||||
results,
|
results,
|
||||||
meta: 'queue-only — Meta IG/FB publisher not wired yet',
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import {
|
|||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Filter,
|
Filter,
|
||||||
|
LayoutGrid,
|
||||||
|
Send,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { PageHeader, Surface } from '@/components/ui';
|
import { PageHeader, Surface } from '@/components/ui';
|
||||||
import {
|
import {
|
||||||
@@ -66,6 +68,9 @@ type PipelineItem = {
|
|||||||
publish_status?: string | null;
|
publish_status?: string | null;
|
||||||
publish_error?: string | null;
|
publish_error?: string | null;
|
||||||
publish_attempts?: number | 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;
|
calendar_piece_id?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -151,6 +156,7 @@ export default function CreativePipelineBoard() {
|
|||||||
const [selectedMonth, setSelectedMonth] = useState<string>('all');
|
const [selectedMonth, setSelectedMonth] = useState<string>('all');
|
||||||
const [selectedFormat, setSelectedFormat] = useState<string>('all');
|
const [selectedFormat, setSelectedFormat] = useState<string>('all');
|
||||||
const [previewItem, setPreviewItem] = useState<PipelineItem | null>(null);
|
const [previewItem, setPreviewItem] = useState<PipelineItem | null>(null);
|
||||||
|
const [viewMode, setViewMode] = useState<'produccion' | 'tablon'>('produccion');
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -260,6 +266,19 @@ export default function CreativePipelineBoard() {
|
|||||||
return Array.from(map.entries()).sort(([a], [b]) => a.localeCompare(b));
|
return Array.from(map.entries()).sort(([a], [b]) => a.localeCompare(b));
|
||||||
}, [filteredItems]);
|
}, [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) {
|
async function createItem(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!newTitle.trim()) return;
|
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);
|
setBusyId(id);
|
||||||
|
setErr(null);
|
||||||
try {
|
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);
|
if (previewItem?.id === id) setPreviewItem(null);
|
||||||
await load();
|
await load();
|
||||||
} finally {
|
} 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(
|
async function uploadImage(
|
||||||
itemId: string,
|
itemId: string,
|
||||||
file: File,
|
file: File,
|
||||||
@@ -422,7 +519,7 @@ export default function CreativePipelineBoard() {
|
|||||||
title="Panel de Creatividades"
|
title="Panel de Creatividades"
|
||||||
description={
|
description={
|
||||||
isAgency
|
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.'
|
: 'Revisa y aprueba las creatividades propuestas por 4Ventures.'
|
||||||
}
|
}
|
||||||
actions={
|
actions={
|
||||||
@@ -451,10 +548,31 @@ export default function CreativePipelineBoard() {
|
|||||||
{/* Barra de Filtros por Mes y Formato */}
|
{/* Barra de Filtros por Mes y Formato */}
|
||||||
<div className="mb-6 space-y-3">
|
<div className="mb-6 space-y-3">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="inline-flex rounded-lg border border-gray-200 bg-white p-0.5">
|
||||||
<span className="text-xs font-semibold uppercase tracking-wider text-gray-500">
|
<button
|
||||||
Meses ({items.length} piezas):
|
type="button"
|
||||||
</span>
|
onClick={() => setViewMode('produccion')}
|
||||||
|
className={`inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-semibold transition-colors ${
|
||||||
|
viewMode === 'produccion'
|
||||||
|
? 'bg-gray-900 text-white'
|
||||||
|
: 'text-gray-600 hover:bg-gray-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<LayoutGrid className="h-3.5 w-3.5" />
|
||||||
|
Producción
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setViewMode('tablon')}
|
||||||
|
className={`inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-semibold transition-colors ${
|
||||||
|
viewMode === 'tablon'
|
||||||
|
? 'bg-gray-900 text-white'
|
||||||
|
: 'text-gray-600 hover:bg-gray-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Instagram className="h-3.5 w-3.5" />
|
||||||
|
Tablón IG
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5 text-xs text-gray-500">
|
<div className="flex items-center gap-1.5 text-xs text-gray-500">
|
||||||
<Filter className="h-3.5 w-3.5" />
|
<Filter className="h-3.5 w-3.5" />
|
||||||
@@ -517,6 +635,12 @@ export default function CreativePipelineBoard() {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
{viewMode === 'tablon' ? (
|
||||||
|
<p className="text-[11px] text-gray-500">
|
||||||
|
Orden: más reciente → más antiguo (fecha de publicación, programación o
|
||||||
|
creación). Simula el feed de Instagram.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showForm && isAgency ? (
|
{showForm && isAgency ? (
|
||||||
@@ -695,6 +819,30 @@ export default function CreativePipelineBoard() {
|
|||||||
No hay piezas con los filtros seleccionados (Mes: {selectedMonth}, Formato: {selectedFormat}).
|
No hay piezas con los filtros seleccionados (Mes: {selectedMonth}, Formato: {selectedFormat}).
|
||||||
</p>
|
</p>
|
||||||
</Surface>
|
</Surface>
|
||||||
|
) : viewMode === 'tablon' ? (
|
||||||
|
<div className="mx-auto max-w-xl space-y-6">
|
||||||
|
{feedItems.map((it) => (
|
||||||
|
<PieceCard
|
||||||
|
key={it.id}
|
||||||
|
item={it}
|
||||||
|
isAgency={isAgency}
|
||||||
|
busy={busyId === it.id}
|
||||||
|
feedStyle
|
||||||
|
onStatus={(s) => 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)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{grouped.map(([month, monthItems]) => (
|
{grouped.map(([month, monthItems]) => (
|
||||||
@@ -715,8 +863,20 @@ export default function CreativePipelineBoard() {
|
|||||||
isAgency={isAgency}
|
isAgency={isAgency}
|
||||||
busy={busyId === it.id}
|
busy={busyId === it.id}
|
||||||
onStatus={(s) => setStatus(it.id, s)}
|
onStatus={(s) => setStatus(it.id, s)}
|
||||||
onDelete={() => removeItem(it.id)}
|
onDelete={() => {
|
||||||
onUpload={(file, slot, index) => uploadImage(it.id, file, slot, index)}
|
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)}
|
onPreview={() => setPreviewItem(it)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -735,6 +895,8 @@ export default function CreativePipelineBoard() {
|
|||||||
onClose={() => setPreviewItem(null)}
|
onClose={() => setPreviewItem(null)}
|
||||||
onStatus={(s) => setStatus(previewItem.id, s)}
|
onStatus={(s) => setStatus(previewItem.id, s)}
|
||||||
onSaveSchedule={(payload) => saveSchedule(previewItem.id, payload)}
|
onSaveSchedule={(payload) => saveSchedule(previewItem.id, payload)}
|
||||||
|
onPublishNow={() => publishNow(previewItem.id)}
|
||||||
|
onDeleteMeta={() => deleteOnMeta(previewItem.id)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -745,6 +907,7 @@ function PieceCard({
|
|||||||
item,
|
item,
|
||||||
isAgency,
|
isAgency,
|
||||||
busy,
|
busy,
|
||||||
|
feedStyle = false,
|
||||||
onStatus,
|
onStatus,
|
||||||
onDelete,
|
onDelete,
|
||||||
onUpload,
|
onUpload,
|
||||||
@@ -753,6 +916,7 @@ function PieceCard({
|
|||||||
item: PipelineItem;
|
item: PipelineItem;
|
||||||
isAgency: boolean;
|
isAgency: boolean;
|
||||||
busy: boolean;
|
busy: boolean;
|
||||||
|
feedStyle?: boolean;
|
||||||
onStatus: (status: string) => void;
|
onStatus: (status: string) => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
onUpload: (file: File, slot: 'cover' | 'slide', index: number) => void;
|
onUpload: (file: File, slot: 'cover' | 'slide', index: number) => void;
|
||||||
@@ -768,11 +932,48 @@ function PieceCard({
|
|||||||
const igUrl = getInstagramUrl(item.notes);
|
const igUrl = getInstagramUrl(item.notes);
|
||||||
|
|
||||||
const clientCanDecide = !isAgency && item.status === 'revision_interna';
|
const clientCanDecide = !isAgency && item.status === 'revision_interna';
|
||||||
|
const when =
|
||||||
|
item.published_at || item.scheduled_at || item.created_at;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Surface padded={false} className="overflow-hidden flex flex-col group transition-shadow hover:shadow-md">
|
<Surface
|
||||||
|
padded={false}
|
||||||
|
className={`overflow-hidden flex flex-col group transition-shadow hover:shadow-md ${
|
||||||
|
feedStyle ? 'rounded-none sm:rounded-xl' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{feedStyle ? (
|
||||||
|
<div className="flex items-center justify-between gap-2 border-b border-gray-100 px-3 py-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-sm font-semibold text-gray-900">
|
||||||
|
{item.title}
|
||||||
|
</p>
|
||||||
|
<p className="text-[11px] text-gray-500">
|
||||||
|
{when
|
||||||
|
? new Date(when).toLocaleString('es-ES', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})
|
||||||
|
: '—'}
|
||||||
|
{item.publish_status === 'published' ? ' · en redes' : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={`shrink-0 rounded-md px-2 py-0.5 text-[10px] font-bold uppercase border ${formatInfo.badgeCls}`}
|
||||||
|
>
|
||||||
|
{formatInfo.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
{/* Portada / Imagen */}
|
{/* Portada / Imagen */}
|
||||||
<div className="relative aspect-[4/5] bg-gray-100 overflow-hidden cursor-pointer" onClick={onPreview}>
|
<div
|
||||||
|
className={`relative bg-gray-100 overflow-hidden cursor-pointer ${
|
||||||
|
feedStyle ? 'aspect-square' : 'aspect-[4/5]'
|
||||||
|
}`}
|
||||||
|
onClick={onPreview}
|
||||||
|
>
|
||||||
{cover ? (
|
{cover ? (
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
<img
|
<img
|
||||||
@@ -997,6 +1198,8 @@ function DetailModal({
|
|||||||
onClose,
|
onClose,
|
||||||
onStatus,
|
onStatus,
|
||||||
onSaveSchedule,
|
onSaveSchedule,
|
||||||
|
onPublishNow,
|
||||||
|
onDeleteMeta,
|
||||||
}: {
|
}: {
|
||||||
item: PipelineItem;
|
item: PipelineItem;
|
||||||
isAgency: boolean;
|
isAgency: boolean;
|
||||||
@@ -1007,6 +1210,8 @@ function DetailModal({
|
|||||||
scheduled_at: string | null;
|
scheduled_at: string | null;
|
||||||
channels: PublishChannel[];
|
channels: PublishChannel[];
|
||||||
}) => void;
|
}) => void;
|
||||||
|
onPublishNow: () => void;
|
||||||
|
onDeleteMeta: () => void;
|
||||||
}) {
|
}) {
|
||||||
const slides = item.slide_paths || [];
|
const slides = item.slide_paths || [];
|
||||||
const allImages = [item.cover_path, ...slides].filter(Boolean) as string[];
|
const allImages = [item.cover_path, ...slides].filter(Boolean) as string[];
|
||||||
@@ -1224,36 +1429,67 @@ function DetailModal({
|
|||||||
{item.publish_error}
|
{item.publish_error}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="flex gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={busy}
|
disabled={
|
||||||
onClick={submitSchedule}
|
busy ||
|
||||||
className="flex-1 rounded-lg bg-sky-700 px-3 py-2 text-xs font-semibold text-white hover:bg-sky-800 disabled:opacity-60"
|
!item.cover_path ||
|
||||||
|
item.publish_status === 'publishing' ||
|
||||||
|
item.publish_status === 'published'
|
||||||
|
}
|
||||||
|
onClick={onPublishNow}
|
||||||
|
className="inline-flex w-full items-center justify-center gap-1.5 rounded-lg bg-gradient-to-r from-purple-600 via-pink-600 to-amber-500 px-3 py-2 text-xs font-bold text-white shadow-sm hover:opacity-95 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
Guardar programación
|
<Send className="h-3.5 w-3.5" />
|
||||||
|
{item.publish_status === 'publishing'
|
||||||
|
? 'Publicando…'
|
||||||
|
: item.publish_status === 'published'
|
||||||
|
? 'Ya publicado en redes'
|
||||||
|
: 'Publicar ahora (IG + FB)'}
|
||||||
</button>
|
</button>
|
||||||
{schedLocal ? (
|
{(item.meta_ig_media_id || item.meta_fb_post_id) && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onClick={() => {
|
onClick={onDeleteMeta}
|
||||||
setSchedLocal('');
|
className="inline-flex w-full items-center justify-center gap-1.5 rounded-lg border border-red-200 bg-white px-3 py-2 text-xs font-semibold text-red-700 hover:bg-red-50 disabled:opacity-60"
|
||||||
onSaveSchedule({
|
|
||||||
scheduled_at: null,
|
|
||||||
channels: ['instagram', 'facebook'],
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
className="rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs font-semibold text-gray-700"
|
|
||||||
>
|
>
|
||||||
Quitar
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
Borrar en Instagram / Facebook
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
)}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={submitSchedule}
|
||||||
|
className="flex-1 rounded-lg bg-sky-700 px-3 py-2 text-xs font-semibold text-white hover:bg-sky-800 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
Guardar programación
|
||||||
|
</button>
|
||||||
|
{schedLocal ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => {
|
||||||
|
setSchedLocal('');
|
||||||
|
onSaveSchedule({
|
||||||
|
scheduled_at: null,
|
||||||
|
channels: ['instagram', 'facebook'],
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs font-semibold text-gray-700"
|
||||||
|
>
|
||||||
|
Quitar
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[10px] text-sky-900/80 leading-relaxed">
|
<p className="text-[10px] text-sky-900/80 leading-relaxed">
|
||||||
Requiere estado <strong>aprobado</strong> para entrar en cola.
|
<strong>Publicar ahora</strong> sale al momento. Con fecha futura +
|
||||||
Publica IG+FB al unísono vía cron (Meta se conecta en el
|
estado <strong>aprobado</strong>, el cron publica IG+FB al unísono.
|
||||||
siguiente paso).
|
Si ya está en redes y está mal: bórralo aquí y vuelve a publicar.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
662
src/lib/meta-publish.ts
Normal file
662
src/lib/meta-publish.ts
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user