From 025fc85175d4c8deeb25ff102355996aec6b96cf Mon Sep 17 00:00:00 2001 From: 4Ventures <4ventures@MacBook-Air-de-4Ventures.local> Date: Thu, 8 Oct 2026 17:17:07 +0200 Subject: [PATCH] Harden Meta publish against dirty captions and IG duplicates. Strip production notes before posting, persist each network id immediately, refuse IG republish if delete fails, and place FB photos via feed for better timeline visibility. Co-authored-by: Cursor --- .../api/creative-pipeline/publish/route.ts | 79 +++++-- src/app/api/creative-pipeline/route.ts | 17 +- src/components/CreativePipelineBoard.tsx | 13 ++ src/lib/meta-publish.ts | 209 +++++++++++------- src/lib/pipeline-publish-worker.ts | 94 ++++++-- src/lib/publish-caption.ts | 76 +++++++ 6 files changed, 368 insertions(+), 120 deletions(-) create mode 100644 src/lib/publish-caption.ts diff --git a/src/app/api/creative-pipeline/publish/route.ts b/src/app/api/creative-pipeline/publish/route.ts index 4b891d0..081c86e 100644 --- a/src/app/api/creative-pipeline/publish/route.ts +++ b/src/app/api/creative-pipeline/publish/route.ts @@ -19,6 +19,8 @@ import { updateLivePipelineItem, type PipelinePublishRow, } from '@/lib/meta-publish'; +import { normalizeChannels } from '@/lib/pipeline-schedule'; +import { sanitizePublishCaption } from '@/lib/publish-caption'; export const dynamic = 'force-dynamic'; export const maxDuration = 120; @@ -108,11 +110,18 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: 'Pieza no encontrada' }, { status: 404 }); } - // Optional caption override before update/publish + // Optional caption override before update/publish (strip production notes) if (typeof body.caption === 'string') { - const caption = body.caption.trim().slice(0, 4000); - await patchItem(id, headers, { caption: caption || null }); - item = { ...item, caption: caption || null }; + const scrub = sanitizePublishCaption(body.caption, { maxLen: 4000 }); + const caption = scrub.caption || null; + await patchItem(id, headers, { caption }); + item = { ...item, caption }; + } else if (item.caption) { + const scrub = sanitizePublishCaption(item.caption, { maxLen: 4000 }); + if (scrub.changed) { + await patchItem(id, headers, { caption: scrub.caption || null }); + item = { ...item, caption: scrub.caption || null }; + } } if (action === 'delete_meta') { @@ -202,6 +211,7 @@ export async function POST(req: NextRequest) { status: 'publicado', meta_ig_media_id: result.igMediaId || null, meta_fb_post_id: result.fbPostId || null, + caption: item.caption || null, notes, }); const rows = okPatch.ok ? await okPatch.json() : null; @@ -235,20 +245,53 @@ export async function POST(req: NextRequest) { : 'aprobado', }); - // Skips networks that already have an id (use update_live to replace). - const result = await publishPipelineItem(item); + // Publish IG then FB, persisting each id immediately (avoids IG duplicates on retry). + const channels = normalizeChannels(item.channels); + const errors: string[] = []; + let igMediaId: string | null = item.meta_ig_media_id || null; + let fbPostId: string | null = item.meta_fb_post_id || null; + let igPermalink: string | null = null; + let fbPermalink: string | null = null; - if (!result.ok) { + if (channels.includes('instagram') && !igMediaId) { + const ig = await publishPipelineItem(item, { skipFb: true }); + if (ig.igMediaId) { + igMediaId = ig.igMediaId; + igPermalink = ig.igPermalink || null; + await patchItem(id, headers, { meta_ig_media_id: igMediaId }); + item = { ...item, meta_ig_media_id: igMediaId }; + } + if (!ig.ok) errors.push(ig.error || 'IG falló'); + } + if (channels.includes('facebook') && !fbPostId && errors.length === 0) { + const fb = await publishPipelineItem(item, { skipIg: true }); + if (fb.fbPostId) { + fbPostId = fb.fbPostId; + fbPermalink = fb.fbPermalink || null; + await patchItem(id, headers, { meta_fb_post_id: fbPostId }); + item = { ...item, meta_fb_post_id: fbPostId }; + } + if (!fb.ok) errors.push(fb.error || 'FB falló'); + } + + const wantIg = channels.includes('instagram'); + const wantFb = channels.includes('facebook'); + const ok = + (!wantIg || Boolean(igMediaId)) && + (!wantFb || Boolean(fbPostId)) && + errors.length === 0; + + if (!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, + publish_error: errors.join(' · ') || 'Error al publicar', + meta_ig_media_id: igMediaId, + meta_fb_post_id: fbPostId, }); const rows = fail.ok ? await fail.json() : null; return NextResponse.json( { - error: result.error || 'Error al publicar', + error: errors.join(' · ') || 'Error al publicar', item: Array.isArray(rows) ? rows[0] : null, }, { status: 502 } @@ -257,25 +300,25 @@ export async function POST(req: NextRequest) { const notes = mergePermalinkNotes( (item as { notes?: string | null }).notes, - result.igPermalink, - result.fbPermalink + igPermalink, + 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, + meta_ig_media_id: igMediaId, + meta_fb_post_id: fbPostId, 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, + igMediaId, + fbPostId, + igPermalink, item: Array.isArray(rows) ? rows[0] : null, }); } diff --git a/src/app/api/creative-pipeline/route.ts b/src/app/api/creative-pipeline/route.ts index c410f01..65c6be5 100644 --- a/src/app/api/creative-pipeline/route.ts +++ b/src/app/api/creative-pipeline/route.ts @@ -21,6 +21,7 @@ import { type CalendarDoc, } from '@/lib/calendar-documents'; import { processDuePipelinePublishes } from '@/lib/pipeline-publish-worker'; +import { sanitizePublishCaption } from '@/lib/publish-caption'; 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'; @@ -174,7 +175,9 @@ export async function POST(req: NextRequest) { let title = str(body.title, 300); let format = str(body.format, 20); let month = str(body.month, 7); - let caption = str(body.caption, 4000); + let caption = + sanitizePublishCaption(str(body.caption, 4000) || '', { maxLen: 4000 }) + .caption || null; let notes = str(body.notes, 2000); let scheduledAt = parseScheduledAt(body.scheduled_at); let channels = 'channels' in body ? normalizeChannels(body.channels) : null; @@ -194,7 +197,10 @@ export async function POST(req: NextRequest) { title = title || cal.title; format = format || mapFormatToPipeline(cal.format, cal.kind); month = month || cal.month; - caption = caption || buildCaption(cal) || null; + caption = + sanitizePublishCaption(caption || buildCaption(cal) || '', { + maxLen: 4000, + }).caption || null; if (!notes) { const bits = [cal.pillar ? `Pilar: ${cal.pillar}` : '', cal.platform] .filter(Boolean) @@ -363,7 +369,12 @@ export async function PATCH(req: NextRequest) { if (month) clean.month = month; const format = str(body.format, 20); if (format && FORMATS.has(format)) clean.format = format; - if ('caption' in body) clean.caption = str(body.caption, 4000); + if ('caption' in body) { + clean.caption = + sanitizePublishCaption(str(body.caption, 4000) || '', { + maxLen: 4000, + }).caption || null; + } if ('notes' in body) clean.notes = str(body.notes, 2000); 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); diff --git a/src/components/CreativePipelineBoard.tsx b/src/components/CreativePipelineBoard.tsx index 74cde3f..c09a861 100644 --- a/src/components/CreativePipelineBoard.tsx +++ b/src/components/CreativePipelineBoard.tsx @@ -29,6 +29,7 @@ import { mapPipelineFormatToCalendar, scheduledAtFromPiece, } from '@/lib/calendar-documents'; +import { captionHasProductionNoise } from '@/lib/publish-caption'; type CalendarOption = { id: string; @@ -1006,6 +1007,12 @@ export default function CreativePipelineBoard() { className="mt-1 w-full rounded-lg border border-gray-200 px-3 py-2 text-sm" placeholder="Texto completo del anuncio" /> + {captionHasProductionNoise(newCaption) && ( +

+ Detectadas notas internas (WhatsApp, PENDIENTE, nombres de + archivo…). Se quitarán al publicar; no las dejes en el copy. +

+ )}
@@ -1788,6 +1795,12 @@ function DetailModal({ className="w-full rounded-xl border border-gray-200 bg-white p-3 text-xs text-gray-800 leading-relaxed" placeholder="Copy del anuncio" /> + {captionHasProductionNoise(editCaption) && ( +

+ Notas internas detectadas (archivos WhatsApp, PENDIENTE, + assignee…). Se eliminan al publicar / actualizar en redes. +

+ )}