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:
@@ -7,9 +7,14 @@ import {
|
||||
userRole,
|
||||
} from '@/lib/agency';
|
||||
import { resolveActiveTenant } from '@/lib/tenant-context';
|
||||
import {
|
||||
normalizeChannels,
|
||||
parseScheduledAt,
|
||||
PUBLISH_STATUSES,
|
||||
} from '@/lib/pipeline-schedule';
|
||||
|
||||
const SELECT =
|
||||
'id,external_ref,title,format,month,status,cover_path,cover_mime,slide_paths,caption,notes,approved_at,created_at,updated_at';
|
||||
'id,external_ref,title,format,month,status,cover_path,cover_mime,slide_paths,caption,notes,approved_at,created_at,updated_at,scheduled_at,channels,publish_status,publish_attempts,published_at,meta_ig_media_id,meta_fb_post_id,publish_error';
|
||||
|
||||
const FORMATS = new Set(['post', 'story', 'reel', 'carrusel']);
|
||||
|
||||
@@ -80,7 +85,10 @@ export async function GET(req: NextRequest) {
|
||||
if (!r.ok) {
|
||||
return NextResponse.json({ items: [], isAgency, warning: await r.text() });
|
||||
}
|
||||
return NextResponse.json({ items: await r.json(), isAgency });
|
||||
return NextResponse.json({
|
||||
items: redactScheduleForClient(await r.json(), isAgency),
|
||||
isAgency,
|
||||
});
|
||||
}
|
||||
|
||||
let query = supabase
|
||||
@@ -95,7 +103,29 @@ export async function GET(req: NextRequest) {
|
||||
if (error) {
|
||||
return NextResponse.json({ items: [], isAgency, warning: error.message });
|
||||
}
|
||||
return NextResponse.json({ items: data ?? [], isAgency });
|
||||
return NextResponse.json({
|
||||
items: redactScheduleForClient(data ?? [], isAgency),
|
||||
isAgency,
|
||||
});
|
||||
}
|
||||
|
||||
function redactScheduleForClient<T extends Record<string, unknown>>(
|
||||
rows: T[],
|
||||
isAgency: boolean
|
||||
): T[] {
|
||||
if (isAgency) return rows;
|
||||
return rows.map((row) => {
|
||||
const {
|
||||
publish_error: _e,
|
||||
publish_attempts: _a,
|
||||
meta_ig_media_id: _i,
|
||||
meta_fb_post_id: _f,
|
||||
publish_status: _p,
|
||||
...rest
|
||||
} = row as T & Record<string, unknown>;
|
||||
// Clients may see scheduled day on calendar later; hide worker internals.
|
||||
return rest as T;
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
@@ -189,7 +219,7 @@ export async function PATCH(req: NextRequest) {
|
||||
const curRes = await fetch(
|
||||
`${supabaseUrl()}/rest/v1/creative_pipeline?id=eq.${encodeURIComponent(
|
||||
id
|
||||
)}&tenant_id=eq.${encodeURIComponent(ctx.tenantId)}&select=status`,
|
||||
)}&tenant_id=eq.${encodeURIComponent(ctx.tenantId)}&select=status,scheduled_at`,
|
||||
{ headers: headers || undefined, cache: 'no-store' }
|
||||
);
|
||||
const curRows = curRes.ok ? await curRes.json() : [];
|
||||
@@ -219,6 +249,15 @@ export async function PATCH(req: NextRequest) {
|
||||
if (next === 'aprobado') {
|
||||
clean.approved_by = user.id;
|
||||
clean.approved_at = new Date().toISOString();
|
||||
// If already scheduled by agency, queue for the worker.
|
||||
if (agency && curRows?.[0]?.scheduled_at) {
|
||||
clean.publish_status = 'queued';
|
||||
}
|
||||
}
|
||||
if (next === 'rechazado' || next === 'propuesta' || next === 'en_diseno') {
|
||||
if (agency) {
|
||||
clean.publish_status = 'idle';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -235,6 +274,45 @@ export async function PATCH(req: NextRequest) {
|
||||
if ('cover_path' in body) clean.cover_path = str(body.cover_path, 500);
|
||||
if ('cover_mime' in body) clean.cover_mime = str(body.cover_mime, 100);
|
||||
if ('slide_paths' in body) clean.slide_paths = slidePaths(body.slide_paths);
|
||||
|
||||
// Scheduling — agency only. Client never receives these keys.
|
||||
if ('scheduled_at' in body) {
|
||||
const parsed = parseScheduledAt(body.scheduled_at);
|
||||
if (parsed === undefined) {
|
||||
return NextResponse.json(
|
||||
{ error: 'scheduled_at inválido' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
clean.scheduled_at = parsed;
|
||||
if (parsed) {
|
||||
// Re-arm worker if agency reschedules after a failure.
|
||||
if (
|
||||
current === 'aprobado' ||
|
||||
clean.status === 'aprobado' ||
|
||||
!clean.status
|
||||
) {
|
||||
clean.publish_status = 'queued';
|
||||
clean.publish_error = null;
|
||||
}
|
||||
} else {
|
||||
clean.publish_status = 'idle';
|
||||
clean.publish_error = null;
|
||||
}
|
||||
}
|
||||
if ('channels' in body) {
|
||||
clean.channels = normalizeChannels(body.channels);
|
||||
}
|
||||
if (
|
||||
typeof body.publish_status === 'string' &&
|
||||
(PUBLISH_STATUSES as readonly string[]).includes(body.publish_status)
|
||||
) {
|
||||
// Manual reset only — never allow client path here.
|
||||
clean.publish_status = body.publish_status;
|
||||
if (body.publish_status === 'idle' || body.publish_status === 'queued') {
|
||||
clean.publish_error = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!headers) {
|
||||
|
||||
143
src/app/api/cron/pipeline-publish/route.ts
Normal file
143
src/app/api/cron/pipeline-publish/route.ts
Normal 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',
|
||||
});
|
||||
}
|
||||
@@ -107,14 +107,14 @@ export default async function CalendarioPage({
|
||||
let pipe: PipelineCalendarRow[] = [];
|
||||
if (agency && ctx.viaView) {
|
||||
const r = await restGet<PipelineCalendarRow[]>(
|
||||
`/rest/v1/creative_pipeline?tenant_id=eq.${encodeURIComponent(ctx.tenantId)}&select=id,external_ref,title,format,month,status,cover_path,slide_paths,caption,notes,created_at&order=created_at.asc&limit=2000`
|
||||
`/rest/v1/creative_pipeline?tenant_id=eq.${encodeURIComponent(ctx.tenantId)}&select=id,external_ref,title,format,month,status,cover_path,slide_paths,caption,notes,created_at,scheduled_at,channels,publish_status&order=created_at.asc&limit=2000`
|
||||
);
|
||||
pipe = r.data || [];
|
||||
} else {
|
||||
const { data } = await supabase
|
||||
.from('creative_pipeline')
|
||||
.select(
|
||||
'id,external_ref,title,format,month,status,cover_path,slide_paths,caption,notes,created_at'
|
||||
'id,external_ref,title,format,month,status,cover_path,slide_paths,caption,notes,created_at,scheduled_at,channels,publish_status'
|
||||
)
|
||||
.order('created_at', { ascending: true })
|
||||
.limit(2000);
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
/**
|
||||
* Pipeline de creatividades — cola de aprobación con preview de placement
|
||||
* (Meta/Google Ads + RRSS), estilo Ad Tracker.
|
||||
* Pipeline de creatividades — producción editorial (agencia programa IG/FB).
|
||||
* El cliente solo ve y aprueba/rechaza en revisión interna.
|
||||
*/
|
||||
'use client';
|
||||
|
||||
import CreativesPipeline from '@/components/CreativesPipeline';
|
||||
import CreativePipelineBoard from '@/components/CreativePipelineBoard';
|
||||
|
||||
export default function CreativesPage() {
|
||||
return <CreativesPipeline />;
|
||||
return <CreativePipelineBoard />;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user