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,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user