Harden scheduled Meta publish and auto-link Drive for pipeline creatives.
Schedule save auto-approves and publishes when due; cron recovers stale jobs every 5m. Drive sync auto-matches client folders under Clientes when not linked. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -20,6 +20,7 @@ import {
|
||||
scheduledAtFromPiece,
|
||||
type CalendarDoc,
|
||||
} from '@/lib/calendar-documents';
|
||||
import { processDuePipelinePublishes } from '@/lib/pipeline-publish-worker';
|
||||
|
||||
const SELECT =
|
||||
'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,calendar_piece_id';
|
||||
@@ -381,15 +382,15 @@ export async function PATCH(req: NextRequest) {
|
||||
}
|
||||
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;
|
||||
// Always arm the worker + ensure editorial status is aprobado.
|
||||
// (Cron only publishes queued/aprobado due items.)
|
||||
if (current !== 'publicado' && clean.status !== 'publicado') {
|
||||
clean.status = 'aprobado';
|
||||
clean.approved_by = user.id;
|
||||
clean.approved_at = new Date().toISOString();
|
||||
}
|
||||
clean.publish_status = 'queued';
|
||||
clean.publish_error = null;
|
||||
} else {
|
||||
clean.publish_status = 'idle';
|
||||
clean.publish_error = null;
|
||||
@@ -431,7 +432,47 @@ export async function PATCH(req: NextRequest) {
|
||||
return NextResponse.json({ error: await r.text() }, { status: 500 });
|
||||
}
|
||||
const data = await r.json();
|
||||
return NextResponse.json({ item: Array.isArray(data) ? data[0] : data });
|
||||
let item = Array.isArray(data) ? data[0] : data;
|
||||
|
||||
// If schedule is already due (or within 90s), publish in this request
|
||||
// so we don't depend solely on the Dokploy cron tick.
|
||||
if (
|
||||
agency &&
|
||||
item &&
|
||||
typeof item.scheduled_at === 'string' &&
|
||||
item.publish_status === 'queued' &&
|
||||
item.cover_path
|
||||
) {
|
||||
const dueAt = Date.parse(item.scheduled_at);
|
||||
if (!Number.isNaN(dueAt) && dueAt <= Date.now() + 90_000) {
|
||||
const pub = await processDuePipelinePublishes({
|
||||
limit: 1,
|
||||
tenantId: ctx.tenantId,
|
||||
itemId: id,
|
||||
});
|
||||
const hit = pub.results.find((x) => x.id === item.id);
|
||||
if (hit) {
|
||||
// reload row
|
||||
const again = await fetch(
|
||||
`${supabaseUrl()}/rest/v1/creative_pipeline?id=eq.${encodeURIComponent(
|
||||
id
|
||||
)}&select=${SELECT}&limit=1`,
|
||||
{ headers, cache: 'no-store' }
|
||||
);
|
||||
if (again.ok) {
|
||||
const rows = await again.json();
|
||||
if (rows?.[0]) item = rows[0];
|
||||
}
|
||||
return NextResponse.json({
|
||||
item,
|
||||
publishedNow: hit.ok,
|
||||
publishDetail: hit.detail,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ item });
|
||||
}
|
||||
|
||||
export async function DELETE(req: NextRequest) {
|
||||
|
||||
@@ -9,110 +9,11 @@ import {
|
||||
} from '@/lib/superhuman-docs';
|
||||
import { syncAllClientifyTenants } from '@/lib/clientify-sync';
|
||||
import { syncAllGhlTenants } from '@/lib/gohighlevel-sync';
|
||||
import { serviceHeaders, supabaseUrl } from '@/lib/agency';
|
||||
import {
|
||||
mergePermalinkNotes,
|
||||
publishPipelineItem,
|
||||
type PipelinePublishRow,
|
||||
} from '@/lib/meta-publish';
|
||||
import { processDuePipelinePublishes } from '@/lib/pipeline-publish-worker';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const maxDuration = 300;
|
||||
|
||||
const PIPELINE_SELECT =
|
||||
'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';
|
||||
|
||||
async function runDuePipelinePublish(limit = 5): Promise<Record<string, unknown>> {
|
||||
const headers = serviceHeaders();
|
||||
const base = supabaseUrl();
|
||||
if (!headers || !base) {
|
||||
return { ok: false, error: 'Service role required' };
|
||||
}
|
||||
const now = new Date().toISOString();
|
||||
const url =
|
||||
`${base}/rest/v1/creative_pipeline?status=eq.aprobado` +
|
||||
`&scheduled_at=lte.${encodeURIComponent(now)}` +
|
||||
`&publish_status=in.(idle,queued,failed)` +
|
||||
`&select=${PIPELINE_SELECT}&order=scheduled_at.asc&limit=${limit}`;
|
||||
const list = await fetch(url, { headers, cache: 'no-store' });
|
||||
if (!list.ok) {
|
||||
return { ok: false, error: (await list.text()).slice(0, 200) };
|
||||
}
|
||||
const due = (await list.json()) as (PipelinePublishRow & {
|
||||
title: string;
|
||||
notes?: string | null;
|
||||
})[];
|
||||
const results: { id: string; ok: boolean; detail: string }[] = [];
|
||||
for (const item of due) {
|
||||
if (!item.cover_path) {
|
||||
results.push({ id: item.id, ok: false, detail: 'missing cover' });
|
||||
continue;
|
||||
}
|
||||
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: 'publishing',
|
||||
publish_attempts: (item.publish_attempts || 0) + 1,
|
||||
publish_error: null,
|
||||
updated_at: new Date().toISOString(),
|
||||
}),
|
||||
});
|
||||
const result = await publishPipelineItem(item);
|
||||
if (!result.ok) {
|
||||
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: '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,
|
||||
updated_at: new Date().toISOString(),
|
||||
}),
|
||||
});
|
||||
results.push({
|
||||
id: item.id,
|
||||
ok: false,
|
||||
detail: result.error || 'failed',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
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: '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: mergePermalinkNotes(
|
||||
item.notes,
|
||||
result.igPermalink,
|
||||
result.fbPermalink
|
||||
),
|
||||
updated_at: new Date().toISOString(),
|
||||
}),
|
||||
});
|
||||
results.push({ id: item.id, ok: true, detail: 'published' });
|
||||
}
|
||||
return { ok: true, due: due.length, results };
|
||||
}
|
||||
|
||||
/**
|
||||
* Master daily job: organic + ads + superhuman.
|
||||
* Dokploy (daily ~06:00 UTC):
|
||||
@@ -213,7 +114,7 @@ export async function GET(req: NextRequest) {
|
||||
|
||||
// 5) Pipeline Meta publish (safety net; prefer dedicated */10 cron)
|
||||
try {
|
||||
out.pipelinePublish = await runDuePipelinePublish(5);
|
||||
out.pipelinePublish = await processDuePipelinePublishes({ limit: 5 });
|
||||
} catch (e) {
|
||||
out.pipelinePublish = {
|
||||
ok: false,
|
||||
|
||||
@@ -1,185 +1,37 @@
|
||||
/**
|
||||
* Cron: pick creative_pipeline rows that are aprobado + scheduled_at due,
|
||||
* publish to Meta IG+FB via Content Publishing API.
|
||||
* Cron: publish due creative_pipeline rows to Meta IG+FB.
|
||||
*
|
||||
* 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';
|
||||
import {
|
||||
mergePermalinkNotes,
|
||||
publishPipelineItem,
|
||||
type PipelinePublishRow,
|
||||
} from '@/lib/meta-publish';
|
||||
import { assertCronAuth } from '@/lib/cron-auth';
|
||||
import { processDuePipelinePublishes } from '@/lib/pipeline-publish-worker';
|
||||
|
||||
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,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();
|
||||
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;
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
const denied = assertCronAuth(req);
|
||||
if (denied) return denied;
|
||||
|
||||
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') || 10))
|
||||
);
|
||||
const limit = Number(req.nextUrl.searchParams.get('limit') || 10);
|
||||
const dryRun = req.nextUrl.searchParams.get('dry') === '1';
|
||||
const tenantId =
|
||||
req.nextUrl.searchParams.get('tenantId')?.trim() || undefined;
|
||||
|
||||
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) {
|
||||
const out = await processDuePipelinePublishes({ limit, dryRun, tenantId });
|
||||
if (!out.ok && out.error) {
|
||||
return NextResponse.json(
|
||||
{ error: (await list.text()).slice(0, 400) },
|
||||
{ error: out.error },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
const due = (await list.json()) as (PipelinePublishRow & {
|
||||
title: string;
|
||||
publish_status: string;
|
||||
notes?: string | null;
|
||||
})[];
|
||||
|
||||
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 as { scheduled_at?: string }).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.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: false,
|
||||
detail: 'missing cover',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
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: '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: `published ig=${result.igMediaId || '-'} fb=${result.fbPostId || '-'}`,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
processed: results.length,
|
||||
results,
|
||||
});
|
||||
return NextResponse.json(out);
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return GET(req);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user