Add agency-only schedule fields and queue for pipeline creatives.

Approved pieces can be timed for IG/FB from the agency board; cron queues due jobs and calendar uses scheduled_at. Meta publish comes next.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
4Ventures
2026-10-06 17:54:00 +02:00
parent 392af11060
commit 12ecde1b3b
9 changed files with 629 additions and 13 deletions

View File

@@ -0,0 +1,143 @@
/**
* 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().
*
* Auth: Authorization: Bearer $CRON_SECRET
* curl -sS -H "Authorization: Bearer $CRON_SECRET" \
* "https://app.4ventures.es/api/cron/pipeline-publish"
*/
import { NextRequest, NextResponse } from 'next/server';
import { serviceHeaders, supabaseUrl } from '@/lib/agency';
export const dynamic = 'force-dynamic';
export const runtime = 'nodejs';
const SELECT =
'id,tenant_id,title,format,status,scheduled_at,channels,publish_status,publish_attempts,cover_path,caption';
function authorize(req: NextRequest): boolean {
const secret = process.env.CRON_SECRET?.trim();
if (!secret) return false;
const auth = req.headers.get('authorization') || '';
if (auth === `Bearer ${secret}`) return true;
const q = req.nextUrl.searchParams.get('secret');
return q === secret;
}
export async function GET(req: NextRequest) {
if (!authorize(req)) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const headers = serviceHeaders();
const base = supabaseUrl();
if (!headers || !base) {
return NextResponse.json({ error: 'Service role required' }, { status: 503 });
}
const now = new Date().toISOString();
const limit = Math.min(
50,
Math.max(1, Number(req.nextUrl.searchParams.get('limit') || 20))
);
const dryRun = req.nextUrl.searchParams.get('dry') === '1';
const url =
`${base}/rest/v1/creative_pipeline?status=eq.aprobado` +
`&scheduled_at=lte.${encodeURIComponent(now)}` +
`&publish_status=in.(idle,queued,failed)` +
`&select=${SELECT}&order=scheduled_at.asc&limit=${limit}`;
const list = await fetch(url, { headers, cache: 'no-store' });
if (!list.ok) {
return NextResponse.json(
{ error: (await list.text()).slice(0, 400) },
{ status: 500 }
);
}
const due = (await list.json()) as {
id: string;
tenant_id: string;
title: string;
scheduled_at: string;
channels: string[];
publish_attempts: number;
publish_status: string;
}[];
if (dryRun) {
return NextResponse.json({
ok: true,
dry: true,
due: due.length,
items: due.map((d) => ({
id: d.id,
title: d.title,
scheduled_at: d.scheduled_at,
channels: d.channels,
attempts: d.publish_attempts,
})),
});
}
const results: {
id: string;
title: string;
ok: boolean;
detail: string;
}[] = [];
for (const item of due) {
if (item.publish_status === 'queued') {
results.push({
id: item.id,
title: item.title,
ok: true,
detail: 'already queued — waiting Meta publisher',
});
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(),
}),
}
);
if (!lock.ok) {
results.push({
id: item.id,
title: item.title,
ok: false,
detail: 'queue mark failed',
});
continue;
}
results.push({
id: item.id,
title: item.title,
ok: true,
detail: 'queued for Meta publish',
});
}
return NextResponse.json({
ok: true,
processed: results.length,
results,
meta: 'queue-only — Meta IG/FB publisher not wired yet',
});
}