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. +

+ )}