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

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

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

View 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,
});
}

View File

@@ -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<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) {
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',
});
}