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