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 <cursoragent@cursor.com>
This commit is contained in:
@@ -19,6 +19,8 @@ import {
|
|||||||
updateLivePipelineItem,
|
updateLivePipelineItem,
|
||||||
type PipelinePublishRow,
|
type PipelinePublishRow,
|
||||||
} from '@/lib/meta-publish';
|
} from '@/lib/meta-publish';
|
||||||
|
import { normalizeChannels } from '@/lib/pipeline-schedule';
|
||||||
|
import { sanitizePublishCaption } from '@/lib/publish-caption';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
export const maxDuration = 120;
|
export const maxDuration = 120;
|
||||||
@@ -108,11 +110,18 @@ export async function POST(req: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'Pieza no encontrada' }, { status: 404 });
|
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') {
|
if (typeof body.caption === 'string') {
|
||||||
const caption = body.caption.trim().slice(0, 4000);
|
const scrub = sanitizePublishCaption(body.caption, { maxLen: 4000 });
|
||||||
await patchItem(id, headers, { caption: caption || null });
|
const caption = scrub.caption || null;
|
||||||
item = { ...item, caption: 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') {
|
if (action === 'delete_meta') {
|
||||||
@@ -202,6 +211,7 @@ export async function POST(req: NextRequest) {
|
|||||||
status: 'publicado',
|
status: 'publicado',
|
||||||
meta_ig_media_id: result.igMediaId || null,
|
meta_ig_media_id: result.igMediaId || null,
|
||||||
meta_fb_post_id: result.fbPostId || null,
|
meta_fb_post_id: result.fbPostId || null,
|
||||||
|
caption: item.caption || null,
|
||||||
notes,
|
notes,
|
||||||
});
|
});
|
||||||
const rows = okPatch.ok ? await okPatch.json() : null;
|
const rows = okPatch.ok ? await okPatch.json() : null;
|
||||||
@@ -235,20 +245,53 @@ export async function POST(req: NextRequest) {
|
|||||||
: 'aprobado',
|
: 'aprobado',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Skips networks that already have an id (use update_live to replace).
|
// Publish IG then FB, persisting each id immediately (avoids IG duplicates on retry).
|
||||||
const result = await publishPipelineItem(item);
|
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, {
|
const fail = await patchItem(id, headers, {
|
||||||
publish_status: 'failed',
|
publish_status: 'failed',
|
||||||
publish_error: result.error || 'Error al publicar',
|
publish_error: errors.join(' · ') || 'Error al publicar',
|
||||||
meta_ig_media_id: result.igMediaId || item.meta_ig_media_id || null,
|
meta_ig_media_id: igMediaId,
|
||||||
meta_fb_post_id: result.fbPostId || item.meta_fb_post_id || null,
|
meta_fb_post_id: fbPostId,
|
||||||
});
|
});
|
||||||
const rows = fail.ok ? await fail.json() : null;
|
const rows = fail.ok ? await fail.json() : null;
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
error: result.error || 'Error al publicar',
|
error: errors.join(' · ') || 'Error al publicar',
|
||||||
item: Array.isArray(rows) ? rows[0] : null,
|
item: Array.isArray(rows) ? rows[0] : null,
|
||||||
},
|
},
|
||||||
{ status: 502 }
|
{ status: 502 }
|
||||||
@@ -257,25 +300,25 @@ export async function POST(req: NextRequest) {
|
|||||||
|
|
||||||
const notes = mergePermalinkNotes(
|
const notes = mergePermalinkNotes(
|
||||||
(item as { notes?: string | null }).notes,
|
(item as { notes?: string | null }).notes,
|
||||||
result.igPermalink,
|
igPermalink,
|
||||||
result.fbPermalink
|
fbPermalink
|
||||||
);
|
);
|
||||||
const okPatch = await patchItem(id, headers, {
|
const okPatch = await patchItem(id, headers, {
|
||||||
publish_status: 'published',
|
publish_status: 'published',
|
||||||
publish_error: null,
|
publish_error: null,
|
||||||
published_at: new Date().toISOString(),
|
published_at: new Date().toISOString(),
|
||||||
status: 'publicado',
|
status: 'publicado',
|
||||||
meta_ig_media_id: result.igMediaId || null,
|
meta_ig_media_id: igMediaId,
|
||||||
meta_fb_post_id: result.fbPostId || null,
|
meta_fb_post_id: fbPostId,
|
||||||
notes,
|
notes,
|
||||||
});
|
});
|
||||||
const rows = okPatch.ok ? await okPatch.json() : null;
|
const rows = okPatch.ok ? await okPatch.json() : null;
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
ok: true,
|
ok: true,
|
||||||
action: 'publish_now',
|
action: 'publish_now',
|
||||||
igMediaId: result.igMediaId,
|
igMediaId,
|
||||||
fbPostId: result.fbPostId,
|
fbPostId,
|
||||||
igPermalink: result.igPermalink,
|
igPermalink,
|
||||||
item: Array.isArray(rows) ? rows[0] : null,
|
item: Array.isArray(rows) ? rows[0] : null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
type CalendarDoc,
|
type CalendarDoc,
|
||||||
} from '@/lib/calendar-documents';
|
} from '@/lib/calendar-documents';
|
||||||
import { processDuePipelinePublishes } from '@/lib/pipeline-publish-worker';
|
import { processDuePipelinePublishes } from '@/lib/pipeline-publish-worker';
|
||||||
|
import { sanitizePublishCaption } from '@/lib/publish-caption';
|
||||||
|
|
||||||
const SELECT =
|
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';
|
'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 title = str(body.title, 300);
|
||||||
let format = str(body.format, 20);
|
let format = str(body.format, 20);
|
||||||
let month = str(body.month, 7);
|
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 notes = str(body.notes, 2000);
|
||||||
let scheduledAt = parseScheduledAt(body.scheduled_at);
|
let scheduledAt = parseScheduledAt(body.scheduled_at);
|
||||||
let channels = 'channels' in body ? normalizeChannels(body.channels) : null;
|
let channels = 'channels' in body ? normalizeChannels(body.channels) : null;
|
||||||
@@ -194,7 +197,10 @@ export async function POST(req: NextRequest) {
|
|||||||
title = title || cal.title;
|
title = title || cal.title;
|
||||||
format = format || mapFormatToPipeline(cal.format, cal.kind);
|
format = format || mapFormatToPipeline(cal.format, cal.kind);
|
||||||
month = month || cal.month;
|
month = month || cal.month;
|
||||||
caption = caption || buildCaption(cal) || null;
|
caption =
|
||||||
|
sanitizePublishCaption(caption || buildCaption(cal) || '', {
|
||||||
|
maxLen: 4000,
|
||||||
|
}).caption || null;
|
||||||
if (!notes) {
|
if (!notes) {
|
||||||
const bits = [cal.pillar ? `Pilar: ${cal.pillar}` : '', cal.platform]
|
const bits = [cal.pillar ? `Pilar: ${cal.pillar}` : '', cal.platform]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
@@ -363,7 +369,12 @@ export async function PATCH(req: NextRequest) {
|
|||||||
if (month) clean.month = month;
|
if (month) clean.month = month;
|
||||||
const format = str(body.format, 20);
|
const format = str(body.format, 20);
|
||||||
if (format && FORMATS.has(format)) clean.format = format;
|
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 ('notes' in body) clean.notes = str(body.notes, 2000);
|
||||||
if ('cover_path' in body) clean.cover_path = str(body.cover_path, 500);
|
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 ('cover_mime' in body) clean.cover_mime = str(body.cover_mime, 100);
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
mapPipelineFormatToCalendar,
|
mapPipelineFormatToCalendar,
|
||||||
scheduledAtFromPiece,
|
scheduledAtFromPiece,
|
||||||
} from '@/lib/calendar-documents';
|
} from '@/lib/calendar-documents';
|
||||||
|
import { captionHasProductionNoise } from '@/lib/publish-caption';
|
||||||
|
|
||||||
type CalendarOption = {
|
type CalendarOption = {
|
||||||
id: string;
|
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"
|
className="mt-1 w-full rounded-lg border border-gray-200 px-3 py-2 text-sm"
|
||||||
placeholder="Texto completo del anuncio"
|
placeholder="Texto completo del anuncio"
|
||||||
/>
|
/>
|
||||||
|
{captionHasProductionNoise(newCaption) && (
|
||||||
|
<p className="mt-1 text-[11px] text-amber-800 bg-amber-50 border border-amber-200 rounded-lg px-2 py-1.5">
|
||||||
|
Detectadas notas internas (WhatsApp, PENDIENTE, nombres de
|
||||||
|
archivo…). Se quitarán al publicar; no las dejes en el copy.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="sm:col-span-2 lg:col-span-3">
|
<div className="sm:col-span-2 lg:col-span-3">
|
||||||
@@ -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"
|
className="w-full rounded-xl border border-gray-200 bg-white p-3 text-xs text-gray-800 leading-relaxed"
|
||||||
placeholder="Copy del anuncio"
|
placeholder="Copy del anuncio"
|
||||||
/>
|
/>
|
||||||
|
{captionHasProductionNoise(editCaption) && (
|
||||||
|
<p className="text-[11px] text-amber-800 bg-amber-50 border border-amber-200 rounded-lg px-2 py-1.5">
|
||||||
|
Notas internas detectadas (archivos WhatsApp, PENDIENTE,
|
||||||
|
assignee…). Se eliminan al publicar / actualizar en redes.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<label className="block text-[11px] text-gray-600">
|
<label className="block text-[11px] text-gray-600">
|
||||||
Fecha plan
|
Fecha plan
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
normalizeChannels,
|
normalizeChannels,
|
||||||
type PublishChannel,
|
type PublishChannel,
|
||||||
} from '@/lib/pipeline-schedule';
|
} from '@/lib/pipeline-schedule';
|
||||||
|
import { sanitizePublishCaption } from '@/lib/publish-caption';
|
||||||
|
|
||||||
const META = 'https://graph.facebook.com/v21.0';
|
const META = 'https://graph.facebook.com/v21.0';
|
||||||
|
|
||||||
@@ -41,6 +42,9 @@ export type PublishResult = {
|
|||||||
fbPermalink?: string | null;
|
fbPermalink?: string | null;
|
||||||
error?: string;
|
error?: string;
|
||||||
channels: PublishChannel[];
|
channels: PublishChannel[];
|
||||||
|
/** Caption actually sent to Meta (production noise stripped). */
|
||||||
|
sanitizedCaption?: string | null;
|
||||||
|
captionStripped?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type GraphJson = Record<string, unknown>;
|
type GraphJson = Record<string, unknown>;
|
||||||
@@ -376,12 +380,17 @@ async function waitIgContainer(
|
|||||||
return { ok: false, error: 'Timeout esperando contenedor IG' };
|
return { ok: false, error: 'Timeout esperando contenedor IG' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function publicCaption(item: PipelinePublishRow, maxLen = 2200): string {
|
||||||
|
const raw = item.caption || item.title || '';
|
||||||
|
return sanitizePublishCaption(raw, { maxLen }).caption || (item.title || '').slice(0, maxLen);
|
||||||
|
}
|
||||||
|
|
||||||
async function publishIgMedia(
|
async function publishIgMedia(
|
||||||
igUserId: string,
|
igUserId: string,
|
||||||
pageToken: string,
|
pageToken: string,
|
||||||
item: PipelinePublishRow
|
item: PipelinePublishRow
|
||||||
): Promise<{ id: string | null; permalink: string | null; error?: string }> {
|
): Promise<{ id: string | null; permalink: string | null; error?: string }> {
|
||||||
const caption = (item.caption || item.title || '').slice(0, 2200);
|
const caption = publicCaption(item, 2200);
|
||||||
const format = (item.format || 'post').toLowerCase();
|
const format = (item.format || 'post').toLowerCase();
|
||||||
const slides = slidePathsOf(item.slide_paths);
|
const slides = slidePathsOf(item.slide_paths);
|
||||||
const cover = item.cover_path || slides[0] || null;
|
const cover = item.cover_path || slides[0] || null;
|
||||||
@@ -503,12 +512,49 @@ async function publishIgMedia(
|
|||||||
return { id: mediaId, permalink };
|
return { id: mediaId, permalink };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function publishFbFeedWithPhotos(
|
||||||
|
pageId: string,
|
||||||
|
pageToken: string,
|
||||||
|
message: string,
|
||||||
|
photoIds: string[]
|
||||||
|
): Promise<{ id: string | null; permalink: string | null; error?: string }> {
|
||||||
|
if (!photoIds.length) {
|
||||||
|
return { id: null, permalink: null, error: 'Sin fotos para el feed FB' };
|
||||||
|
}
|
||||||
|
const form = new URLSearchParams({
|
||||||
|
message,
|
||||||
|
access_token: pageToken,
|
||||||
|
});
|
||||||
|
photoIds.forEach((id, i) => {
|
||||||
|
form.append(`attached_media[${i}]`, JSON.stringify({ media_fbid: id }));
|
||||||
|
});
|
||||||
|
const r = await fetch(`${META}/${encodeURIComponent(pageId)}/feed`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: form.toString(),
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
const raw = await r.text();
|
||||||
|
let json: GraphJson = {};
|
||||||
|
try {
|
||||||
|
json = JSON.parse(raw) as GraphJson;
|
||||||
|
} catch {
|
||||||
|
/* */
|
||||||
|
}
|
||||||
|
if (!r.ok || typeof json.id !== 'string') {
|
||||||
|
return { id: null, permalink: null, error: formatGraphError(raw) };
|
||||||
|
}
|
||||||
|
return { id: json.id, permalink: null };
|
||||||
|
}
|
||||||
|
|
||||||
async function publishFbPost(
|
async function publishFbPost(
|
||||||
pageId: string,
|
pageId: string,
|
||||||
pageToken: string,
|
pageToken: string,
|
||||||
item: PipelinePublishRow
|
item: PipelinePublishRow
|
||||||
): Promise<{ id: string | null; permalink: string | null; error?: string }> {
|
): Promise<{ id: string | null; permalink: string | null; error?: string }> {
|
||||||
const message = (item.caption || item.title || '').slice(0, 6000);
|
// Prefer /feed + unpublished photos so the post lands on the Page timeline
|
||||||
|
// (direct /photos published=true often only shows in Fotos, not on top).
|
||||||
|
const message = publicCaption(item, 6000);
|
||||||
const format = (item.format || 'post').toLowerCase();
|
const format = (item.format || 'post').toLowerCase();
|
||||||
const slides = slidePathsOf(item.slide_paths);
|
const slides = slidePathsOf(item.slide_paths);
|
||||||
const cover = item.cover_path || slides[0] || null;
|
const cover = item.cover_path || slides[0] || null;
|
||||||
@@ -540,68 +586,33 @@ async function publishFbPost(
|
|||||||
return { id: vid.json.id, permalink: null };
|
return { id: vid.json.id, permalink: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (format === 'carrusel') {
|
const paths =
|
||||||
const paths = [cover, ...slides.filter((p) => p !== cover)].slice(0, 10);
|
format === 'carrusel'
|
||||||
const mediaIds: string[] = [];
|
? [cover, ...slides.filter((p) => p !== cover)].slice(0, 10)
|
||||||
for (const path of paths) {
|
: [cover];
|
||||||
const url = await signedAssetUrl(path);
|
const mediaIds: string[] = [];
|
||||||
if (!url) continue;
|
for (const path of paths) {
|
||||||
const ph = await graphPost(`${encodeURIComponent(pageId)}/photos`, pageToken, {
|
const url = await signedAssetUrl(path);
|
||||||
url,
|
if (!url) continue;
|
||||||
published: 'false',
|
const ph = await graphPost(`${encodeURIComponent(pageId)}/photos`, pageToken, {
|
||||||
});
|
url,
|
||||||
if (ph.ok && typeof ph.json.id === 'string') {
|
published: 'false',
|
||||||
mediaIds.push(ph.json.id);
|
});
|
||||||
}
|
if (ph.ok && typeof ph.json.id === 'string') {
|
||||||
}
|
mediaIds.push(ph.json.id);
|
||||||
if (mediaIds.length >= 2) {
|
|
||||||
const form = new URLSearchParams({
|
|
||||||
message,
|
|
||||||
access_token: pageToken,
|
|
||||||
});
|
|
||||||
mediaIds.forEach((id, i) => {
|
|
||||||
form.append(`attached_media[${i}]`, JSON.stringify({ media_fbid: id }));
|
|
||||||
});
|
|
||||||
const r = await fetch(`${META}/${encodeURIComponent(pageId)}/feed`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
||||||
body: form.toString(),
|
|
||||||
cache: 'no-store',
|
|
||||||
});
|
|
||||||
const raw = await r.text();
|
|
||||||
let json: GraphJson = {};
|
|
||||||
try {
|
|
||||||
json = JSON.parse(raw) as GraphJson;
|
|
||||||
} catch {
|
|
||||||
/* */
|
|
||||||
}
|
|
||||||
if (!r.ok || typeof json.id !== 'string') {
|
|
||||||
return { id: null, permalink: null, error: formatGraphError(raw) };
|
|
||||||
}
|
|
||||||
return { id: json.id, permalink: null };
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (format === 'carrusel' && mediaIds.length < 2) {
|
||||||
const url = await signedAssetUrl(cover);
|
return {
|
||||||
if (!url) {
|
id: null,
|
||||||
|
permalink: null,
|
||||||
|
error: 'Carrusel FB necesita al menos 2 imágenes',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!mediaIds.length) {
|
||||||
return { id: null, permalink: null, error: 'No URL firmada FB imagen' };
|
return { id: null, permalink: null, error: 'No URL firmada FB imagen' };
|
||||||
}
|
}
|
||||||
const photo = await graphPost(`${encodeURIComponent(pageId)}/photos`, pageToken, {
|
return publishFbFeedWithPhotos(pageId, pageToken, message, mediaIds);
|
||||||
url,
|
|
||||||
caption: message,
|
|
||||||
published: 'true',
|
|
||||||
});
|
|
||||||
if (
|
|
||||||
!photo.ok ||
|
|
||||||
(typeof photo.json.id !== 'string' && typeof photo.json.post_id !== 'string')
|
|
||||||
) {
|
|
||||||
return { id: null, permalink: null, error: formatGraphError(photo.raw) };
|
|
||||||
}
|
|
||||||
const id =
|
|
||||||
(typeof photo.json.post_id === 'string' && photo.json.post_id) ||
|
|
||||||
(typeof photo.json.id === 'string' && photo.json.id) ||
|
|
||||||
null;
|
|
||||||
return { id, permalink: null };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function publishPipelineItem(
|
export async function publishPipelineItem(
|
||||||
@@ -609,9 +620,22 @@ export async function publishPipelineItem(
|
|||||||
opts?: { forceIg?: boolean; forceFb?: boolean; skipIg?: boolean; skipFb?: boolean }
|
opts?: { forceIg?: boolean; forceFb?: boolean; skipIg?: boolean; skipFb?: boolean }
|
||||||
): Promise<PublishResult> {
|
): Promise<PublishResult> {
|
||||||
const channels = normalizeChannels(item.channels);
|
const channels = normalizeChannels(item.channels);
|
||||||
|
const scrub = sanitizePublishCaption(item.caption || item.title || '', {
|
||||||
|
maxLen: 6000,
|
||||||
|
});
|
||||||
|
const cleanItem: PipelinePublishRow = {
|
||||||
|
...item,
|
||||||
|
caption: scrub.caption || item.title || '',
|
||||||
|
};
|
||||||
const resolved = await resolveTenantMetaTargets(item.tenant_id);
|
const resolved = await resolveTenantMetaTargets(item.tenant_id);
|
||||||
if (!resolved.targets) {
|
if (!resolved.targets) {
|
||||||
return { ok: false, error: resolved.error, channels };
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: resolved.error,
|
||||||
|
channels,
|
||||||
|
sanitizedCaption: scrub.caption,
|
||||||
|
captionStripped: scrub.stripped,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
const { pageId, igUserId, pageToken } = resolved.targets;
|
const { pageId, igUserId, pageToken } = resolved.targets;
|
||||||
|
|
||||||
@@ -628,7 +652,7 @@ export async function publishPipelineItem(
|
|||||||
if (!igUserId) {
|
if (!igUserId) {
|
||||||
errors.push('Sin Instagram Business vinculado a la Página');
|
errors.push('Sin Instagram Business vinculado a la Página');
|
||||||
} else if (!igMediaId) {
|
} else if (!igMediaId) {
|
||||||
const ig = await publishIgMedia(igUserId, pageToken, item);
|
const ig = await publishIgMedia(igUserId, pageToken, cleanItem);
|
||||||
if (ig.error || !ig.id) {
|
if (ig.error || !ig.id) {
|
||||||
errors.push(`IG: ${ig.error || 'fallo'}`);
|
errors.push(`IG: ${ig.error || 'fallo'}`);
|
||||||
} else {
|
} else {
|
||||||
@@ -640,7 +664,7 @@ export async function publishPipelineItem(
|
|||||||
|
|
||||||
if (wantFb) {
|
if (wantFb) {
|
||||||
if (!fbPostId) {
|
if (!fbPostId) {
|
||||||
const fb = await publishFbPost(pageId, pageToken, item);
|
const fb = await publishFbPost(pageId, pageToken, cleanItem);
|
||||||
if (fb.error || !fb.id) {
|
if (fb.error || !fb.id) {
|
||||||
errors.push(`FB: ${fb.error || 'fallo'}`);
|
errors.push(`FB: ${fb.error || 'fallo'}`);
|
||||||
} else {
|
} else {
|
||||||
@@ -662,6 +686,8 @@ export async function publishPipelineItem(
|
|||||||
fbPermalink,
|
fbPermalink,
|
||||||
error: errors.length ? errors.join(' · ') : undefined,
|
error: errors.length ? errors.join(' · ') : undefined,
|
||||||
channels,
|
channels,
|
||||||
|
sanitizedCaption: scrub.caption,
|
||||||
|
captionStripped: scrub.stripped,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -808,9 +834,16 @@ export async function updateLivePipelineItem(
|
|||||||
return { ok: false, error: resolved.error };
|
return { ok: false, error: resolved.error };
|
||||||
}
|
}
|
||||||
const { pageId, igUserId, pageToken } = resolved.targets;
|
const { pageId, igUserId, pageToken } = resolved.targets;
|
||||||
const caption = (item.caption || item.title || '').slice(0, 2200);
|
const scrub = sanitizePublishCaption(item.caption || item.title || '', {
|
||||||
|
maxLen: 2200,
|
||||||
|
});
|
||||||
|
const caption = scrub.caption || (item.title || '').slice(0, 2200);
|
||||||
|
const cleanItem: PipelinePublishRow = { ...item, caption };
|
||||||
const notes: string[] = [];
|
const notes: string[] = [];
|
||||||
const errors: string[] = [];
|
const errors: string[] = [];
|
||||||
|
if (scrub.stripped.length) {
|
||||||
|
notes.push(`Copy: quitadas ${scrub.stripped.length} notas internas`);
|
||||||
|
}
|
||||||
|
|
||||||
let igMediaId: string | null = item.meta_ig_media_id || null;
|
let igMediaId: string | null = item.meta_ig_media_id || null;
|
||||||
let fbPostId: string | null = item.meta_fb_post_id || null;
|
let fbPostId: string | null = item.meta_fb_post_id || null;
|
||||||
@@ -830,7 +863,7 @@ export async function updateLivePipelineItem(
|
|||||||
errors.push(`FB edit: ${formatGraphError(edit.raw)}`);
|
errors.push(`FB edit: ${formatGraphError(edit.raw)}`);
|
||||||
} else {
|
} else {
|
||||||
const pub = await publishFbPost(pageId, pageToken, {
|
const pub = await publishFbPost(pageId, pageToken, {
|
||||||
...item,
|
...cleanItem,
|
||||||
meta_fb_post_id: null,
|
meta_fb_post_id: null,
|
||||||
});
|
});
|
||||||
if (!pub.id) {
|
if (!pub.id) {
|
||||||
@@ -843,7 +876,7 @@ export async function updateLivePipelineItem(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (channels.includes('facebook') && !fbPostId) {
|
} else if (channels.includes('facebook') && !fbPostId) {
|
||||||
const pub = await publishFbPost(pageId, pageToken, item);
|
const pub = await publishFbPost(pageId, pageToken, cleanItem);
|
||||||
if (!pub.id) errors.push(`FB: ${pub.error || 'fallo'}`);
|
if (!pub.id) errors.push(`FB: ${pub.error || 'fallo'}`);
|
||||||
else {
|
else {
|
||||||
fbPostId = pub.id;
|
fbPostId = pub.id;
|
||||||
@@ -851,32 +884,40 @@ export async function updateLivePipelineItem(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Instagram: must delete + republish
|
// Instagram: must delete + republish — never publish a second copy if delete fails.
|
||||||
if (channels.includes('instagram')) {
|
if (channels.includes('instagram')) {
|
||||||
if (!igUserId) {
|
if (!igUserId) {
|
||||||
errors.push('Sin Instagram Business vinculado');
|
errors.push('Sin Instagram Business vinculado');
|
||||||
} else {
|
} else {
|
||||||
|
let canRepublish = true;
|
||||||
if (igMediaId) {
|
if (igMediaId) {
|
||||||
const d = await graphDelete(encodeURIComponent(igMediaId), pageToken);
|
const d = await graphDelete(encodeURIComponent(igMediaId), pageToken);
|
||||||
if (
|
const gone =
|
||||||
!d.ok &&
|
d.ok ||
|
||||||
!formatGraphError(d.raw).toLowerCase().includes('does not exist') &&
|
formatGraphError(d.raw).toLowerCase().includes('does not exist') ||
|
||||||
!formatGraphError(d.raw).toLowerCase().includes('unsupported')
|
formatGraphError(d.raw).toLowerCase().includes('unsupported') ||
|
||||||
) {
|
formatGraphError(d.raw).toLowerCase().includes('no existe');
|
||||||
errors.push(`IG borrar: ${formatGraphError(d.raw)}`);
|
if (!gone) {
|
||||||
|
errors.push(
|
||||||
|
`IG borrar: ${formatGraphError(d.raw)} (no se republica para evitar duplicado)`
|
||||||
|
);
|
||||||
|
canRepublish = false;
|
||||||
|
} else {
|
||||||
|
igMediaId = null;
|
||||||
}
|
}
|
||||||
igMediaId = null;
|
|
||||||
}
|
}
|
||||||
const pub = await publishIgMedia(igUserId, pageToken, {
|
if (canRepublish) {
|
||||||
...item,
|
const pub = await publishIgMedia(igUserId, pageToken, {
|
||||||
meta_ig_media_id: null,
|
...cleanItem,
|
||||||
});
|
meta_ig_media_id: null,
|
||||||
if (!pub.id) {
|
});
|
||||||
errors.push(`IG republicar: ${pub.error || 'fallo'}`);
|
if (!pub.id) {
|
||||||
} else {
|
errors.push(`IG republicar: ${pub.error || 'fallo'}`);
|
||||||
igMediaId = pub.id;
|
} else {
|
||||||
igPermalink = pub.permalink;
|
igMediaId = pub.id;
|
||||||
notes.push('IG: republicado con nuevo copy');
|
igPermalink = pub.permalink;
|
||||||
|
notes.push('IG: republicado con nuevo copy');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import {
|
|||||||
publishPipelineItem,
|
publishPipelineItem,
|
||||||
type PipelinePublishRow,
|
type PipelinePublishRow,
|
||||||
} from '@/lib/meta-publish';
|
} from '@/lib/meta-publish';
|
||||||
|
import { normalizeChannels } from '@/lib/pipeline-schedule';
|
||||||
|
import { sanitizePublishCaption } from '@/lib/publish-caption';
|
||||||
|
|
||||||
const SELECT =
|
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,updated_at';
|
'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,updated_at';
|
||||||
@@ -156,7 +158,8 @@ export async function processDuePipelinePublishes(opts?: {
|
|||||||
|
|
||||||
const results: DuePublishResult[] = [];
|
const results: DuePublishResult[] = [];
|
||||||
|
|
||||||
for (const item of slice) {
|
for (const due of slice) {
|
||||||
|
let item: typeof due = due;
|
||||||
const attempts = (item.publish_attempts || 0) + 1;
|
const attempts = (item.publish_attempts || 0) + 1;
|
||||||
const lock = await patchItem(base, headers, item.id, {
|
const lock = await patchItem(base, headers, item.id, {
|
||||||
publish_status: 'publishing',
|
publish_status: 'publishing',
|
||||||
@@ -174,42 +177,103 @@ export async function processDuePipelinePublishes(opts?: {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await publishPipelineItem(item);
|
// Sanitize + persist clean caption before hitting Meta.
|
||||||
if (!result.ok) {
|
const scrub = sanitizePublishCaption(item.caption || item.title || '', {
|
||||||
|
maxLen: 6000,
|
||||||
|
});
|
||||||
|
const cleanCaption = scrub.caption || item.title || '';
|
||||||
|
if (scrub.changed && cleanCaption !== (item.caption || '')) {
|
||||||
|
await patchItem(base, headers, item.id, { caption: cleanCaption || null });
|
||||||
|
item = { ...item, caption: cleanCaption };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Publish IG then FB in two steps, persisting each Meta id immediately
|
||||||
|
// so a crash / FB failure cannot create a duplicate IG 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 (channels.includes('instagram') && !igMediaId) {
|
||||||
|
const ig = await publishPipelineItem(item, { skipFb: true });
|
||||||
|
if (ig.sanitizedCaption && ig.sanitizedCaption !== (item.caption || '')) {
|
||||||
|
await patchItem(base, headers, item.id, {
|
||||||
|
caption: ig.sanitizedCaption,
|
||||||
|
});
|
||||||
|
item = { ...item, caption: ig.sanitizedCaption };
|
||||||
|
}
|
||||||
|
if (ig.igMediaId) {
|
||||||
|
igMediaId = ig.igMediaId;
|
||||||
|
igPermalink = ig.igPermalink || null;
|
||||||
|
await patchItem(base, headers, item.id, {
|
||||||
|
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, meta_ig_media_id: igMediaId },
|
||||||
|
{ skipIg: true }
|
||||||
|
);
|
||||||
|
if (fb.fbPostId) {
|
||||||
|
fbPostId = fb.fbPostId;
|
||||||
|
fbPermalink = fb.fbPermalink || null;
|
||||||
|
await patchItem(base, headers, item.id, {
|
||||||
|
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 igOk = !wantIg || Boolean(igMediaId);
|
||||||
|
const fbOk = !wantFb || Boolean(fbPostId);
|
||||||
|
const ok = igOk && fbOk && errors.length === 0;
|
||||||
|
|
||||||
|
if (!ok) {
|
||||||
await patchItem(base, headers, item.id, {
|
await patchItem(base, headers, item.id, {
|
||||||
publish_status: 'failed',
|
publish_status: 'failed',
|
||||||
publish_error: result.error || 'Error Meta',
|
publish_error: errors.join(' · ') || 'Error Meta',
|
||||||
meta_ig_media_id: result.igMediaId || item.meta_ig_media_id || null,
|
meta_ig_media_id: igMediaId,
|
||||||
meta_fb_post_id: result.fbPostId || item.meta_fb_post_id || null,
|
meta_fb_post_id: fbPostId,
|
||||||
|
caption: cleanCaption || null,
|
||||||
});
|
});
|
||||||
results.push({
|
results.push({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
title: item.title,
|
title: item.title,
|
||||||
ok: false,
|
ok: false,
|
||||||
detail: result.error || 'publish failed',
|
detail: errors.join(' · ') || 'publish failed',
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const notes = mergePermalinkNotes(
|
const notes = mergePermalinkNotes(item.notes, igPermalink, fbPermalink);
|
||||||
item.notes,
|
|
||||||
result.igPermalink,
|
|
||||||
result.fbPermalink
|
|
||||||
);
|
|
||||||
await patchItem(base, headers, item.id, {
|
await patchItem(base, headers, item.id, {
|
||||||
publish_status: 'published',
|
publish_status: 'published',
|
||||||
publish_error: null,
|
publish_error: null,
|
||||||
published_at: new Date().toISOString(),
|
published_at: new Date().toISOString(),
|
||||||
status: 'publicado',
|
status: 'publicado',
|
||||||
meta_ig_media_id: result.igMediaId || null,
|
meta_ig_media_id: igMediaId,
|
||||||
meta_fb_post_id: result.fbPostId || null,
|
meta_fb_post_id: fbPostId,
|
||||||
|
caption: cleanCaption || null,
|
||||||
notes,
|
notes,
|
||||||
});
|
});
|
||||||
results.push({
|
results.push({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
title: item.title,
|
title: item.title,
|
||||||
ok: true,
|
ok: true,
|
||||||
detail: `published ig=${result.igMediaId || '-'} fb=${result.fbPostId || '-'}`,
|
detail: `published ig=${igMediaId || '-'} fb=${fbPostId || '-'}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
76
src/lib/publish-caption.ts
Normal file
76
src/lib/publish-caption.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
/**
|
||||||
|
* Sanitize captions before Meta publish.
|
||||||
|
* Strips internal production notes that sometimes get pasted from
|
||||||
|
* briefings / Excel / WhatsApp (filenames, status, assignee).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const FILE_NAME_LINE =
|
||||||
|
/^(?:whatsapp\s+image|img|image|foto|captura|screenshot|screen\s*shot)[\s_\-.].*$/i;
|
||||||
|
const EXT_FILE_LINE =
|
||||||
|
/^[\w.\- ()]+\.(?:jpe?g|png|heic|webp|mov|mp4|pdf)$/i;
|
||||||
|
const STATUS_LINE =
|
||||||
|
/^(?:pendiente(?:\s+publicar)?|para\s+publicar|en\s+proceso|borrador|draft|to\s+publish|ready\s+to\s+post|en\s+feedback|link\s+pieza(?:\s+en\s+alta)?|copy\s*:?|fecha\s+de\s+publicaci[oó]n\s*:.*)$/i;
|
||||||
|
/** Lone "Name Surname." / "Name Surname" production assignee lines */
|
||||||
|
const ASSIGNEE_LINE = /^[A-ZÁÉÍÓÚÑ][a-záéíóúñ]+(?:\s+[A-ZÁÉÍÓÚÑ][a-záéíóúñ]+){1,3}\.?$/;
|
||||||
|
|
||||||
|
export type CaptionSanitizeResult = {
|
||||||
|
caption: string;
|
||||||
|
stripped: string[];
|
||||||
|
changed: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function looksLikeProductionNoise(line: string): boolean {
|
||||||
|
const t = line.trim();
|
||||||
|
if (!t) return false;
|
||||||
|
if (FILE_NAME_LINE.test(t)) return true;
|
||||||
|
if (EXT_FILE_LINE.test(t)) return true;
|
||||||
|
if (STATUS_LINE.test(t)) return true;
|
||||||
|
// Short assignee-only lines (e.g. "Miriam Álvarez.") — only if ≤ 40 chars
|
||||||
|
if (t.length <= 40 && ASSIGNEE_LINE.test(t) && !t.includes('@') && !/[?!]/.test(t)) {
|
||||||
|
// Avoid stripping real closing lines that are all-caps slogans
|
||||||
|
if (!/[A-ZÁÉÍÓÚÑ]{4,}/.test(t.replace(/[^A-Za-zÁÉÍÓÚáéíóúÑñ]/g, ''))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function captionHasProductionNoise(raw: string | null | undefined): boolean {
|
||||||
|
if (!raw?.trim()) return false;
|
||||||
|
return raw.split(/\r?\n/).some((l) => looksLikeProductionNoise(l));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove production-noise lines and trailing junk after the real copy.
|
||||||
|
* Does not invent content — only strips.
|
||||||
|
*/
|
||||||
|
export function sanitizePublishCaption(
|
||||||
|
raw: string | null | undefined,
|
||||||
|
opts?: { maxLen?: number }
|
||||||
|
): CaptionSanitizeResult {
|
||||||
|
const maxLen = opts?.maxLen ?? 2200;
|
||||||
|
const input = (raw || '').replace(/\r\n/g, '\n').trim();
|
||||||
|
if (!input) {
|
||||||
|
return { caption: '', stripped: [], changed: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const stripped: string[] = [];
|
||||||
|
const kept: string[] = [];
|
||||||
|
for (const line of input.split('\n')) {
|
||||||
|
if (looksLikeProductionNoise(line)) {
|
||||||
|
stripped.push(line.trim());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
kept.push(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collapse trailing empty lines left by stripping
|
||||||
|
let caption = kept.join('\n').replace(/\n{3,}/g, '\n\n').trim();
|
||||||
|
if (caption.length > maxLen) caption = caption.slice(0, maxLen).trim();
|
||||||
|
|
||||||
|
return {
|
||||||
|
caption,
|
||||||
|
stripped,
|
||||||
|
changed: caption !== input.slice(0, maxLen).trim() || stripped.length > 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user