From 77fd62807809cbdf212cb3ce0448d19c1c3072d9 Mon Sep 17 00:00:00 2001 From: 4Ventures <4ventures@MacBook-Air-de-4Ventures.local> Date: Fri, 16 Oct 2026 13:05:30 +0200 Subject: [PATCH] fix(pipeline): editar formato Post/Story y bloquear horarios pasados MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Permite cambiar el formato al editar una pieza, normaliza Stories→story al guardar y rechaza programaciones en fecha/hora ya pasada. Co-authored-by: Cursor --- src/app/api/calendar/month/route.ts | 2 +- .../api/creative-pipeline/generate/route.ts | 25 ++- src/app/api/creative-pipeline/route.ts | 32 +++- src/components/CreativePipelineBoard.tsx | 172 +++++++++++++++--- src/lib/calendar-documents.ts | 18 +- src/lib/pipeline-schedule.ts | 58 ++++++ 6 files changed, 270 insertions(+), 37 deletions(-) diff --git a/src/app/api/calendar/month/route.ts b/src/app/api/calendar/month/route.ts index 6571550..cd6df3d 100644 --- a/src/app/api/calendar/month/route.ts +++ b/src/app/api/calendar/month/route.ts @@ -189,7 +189,7 @@ async function syncPieceToPipeline( const existing = find.ok ? ((await find.json()) as { id: string }[]) : []; const row: Record = { title: piece.title, - format: mapFormatToPipeline(piece.format, piece.kind), + format: mapFormatToPipeline(piece.format, piece.kind) || 'post', month, caption: caption || null, notes: piece.pillar ? `Pilar: ${piece.pillar}` : null, diff --git a/src/app/api/creative-pipeline/generate/route.ts b/src/app/api/creative-pipeline/generate/route.ts index f6ec110..d3ada77 100644 --- a/src/app/api/creative-pipeline/generate/route.ts +++ b/src/app/api/creative-pipeline/generate/route.ts @@ -11,9 +11,13 @@ import { import { buildRrssGenerationBrief, generationRequestNoteMarker, - isRrssPipelineFormat, type RrssPipelineFormat, } from '@/lib/rrss-generation'; +import { + isScheduledAtInPast, + normalizePipelineFormat, + parseScheduledAt, +} from '@/lib/pipeline-schedule'; import { createClient as createAdminClient } from '@supabase/supabase-js'; export const dynamic = 'force-dynamic'; @@ -58,13 +62,23 @@ export async function POST(req: NextRequest) { } const title = String(body.title || '').trim(); - const format = body.format as RrssPipelineFormat; + const format = normalizePipelineFormat(body.format) as RrssPipelineFormat; if (!title) { return NextResponse.json({ error: 'title requerido' }, { status: 400 }); } - if (!isRrssPipelineFormat(format)) { + const scheduledAt = parseScheduledAt(body.scheduled_at); + if (scheduledAt === undefined) { return NextResponse.json( - { error: 'format debe ser post|story|reel|carrusel' }, + { error: 'scheduled_at inválido' }, + { status: 400 } + ); + } + if (scheduledAt && isScheduledAtInPast(scheduledAt)) { + return NextResponse.json( + { + error: + 'No se puede programar en una fecha/hora ya pasada. Elige un horario futuro.', + }, { status: 400 } ); } @@ -160,8 +174,7 @@ export async function POST(req: NextRequest) { caption, notes: notesBase || null, calendar_piece_id: calendarPieceId, - scheduled_at: - typeof body.scheduled_at === 'string' ? body.scheduled_at : null, + scheduled_at: scheduledAt ?? null, channels: Array.isArray(body.channels) ? body.channels : ['instagram', 'facebook'], diff --git a/src/app/api/creative-pipeline/route.ts b/src/app/api/creative-pipeline/route.ts index 6a0ffd8..0904c39 100644 --- a/src/app/api/creative-pipeline/route.ts +++ b/src/app/api/creative-pipeline/route.ts @@ -9,7 +9,9 @@ import { import { resolveActiveTenant } from '@/lib/tenant-context'; import { channelsForCalendarPlatform, + isScheduledAtInPast, normalizeChannels, + normalizePipelineFormat, parseScheduledAt, PUBLISH_STATUSES, } from '@/lib/pipeline-schedule'; @@ -36,8 +38,6 @@ import { dispatchN8nWebhookAsync } from '@/lib/creatives-webhooks'; 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'; -const FORMATS = new Set(['post', 'story', 'reel', 'carrusel']); - function str(v: unknown, max = 2000): string | null { if (typeof v !== 'string') return null; const t = v.trim(); @@ -190,7 +190,7 @@ export async function POST(req: NextRequest) { const calendarPieceId = str(body.calendar_piece_id, 120); let title = str(body.title, 300); - let format = str(body.format, 20); + let format = str(body.format, 40); let month = str(body.month, 7); let caption = sanitizePublishCaption(str(body.caption, 4000) || '', { maxLen: 4000 }) @@ -237,11 +237,21 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: 'title requerido' }, { status: 400 }); } + if (scheduledAt && isScheduledAtInPast(scheduledAt)) { + return NextResponse.json( + { + error: + 'No se puede programar en una fecha/hora ya pasada. Elige un horario futuro.', + }, + { status: 400 } + ); + } + const row: Record = { tenant_id: ctx.tenantId, title, external_ref: str(body.external_ref, 80), - format: format && FORMATS.has(format) ? format : 'post', + format: normalizePipelineFormat(format), month: month || null, caption: caption || null, notes: notes || null, @@ -422,8 +432,9 @@ export async function PATCH(req: NextRequest) { if (title) clean.title = title; const month = str(body.month, 7); if (month) clean.month = month; - const format = str(body.format, 20); - if (format && FORMATS.has(format)) clean.format = format; + if ('format' in body && body.format != null) { + clean.format = normalizePipelineFormat(body.format); + } if ('caption' in body) { clean.caption = sanitizePublishCaption(str(body.caption, 4000) || '', { @@ -447,6 +458,15 @@ export async function PATCH(req: NextRequest) { { status: 400 } ); } + if (parsed && isScheduledAtInPast(parsed)) { + return NextResponse.json( + { + error: + 'No se puede programar en una fecha/hora ya pasada. Elige un horario futuro.', + }, + { status: 400 } + ); + } clean.scheduled_at = parsed; if (parsed) { // Cron publica queued + status aprobada. Programar implica ya validada. diff --git a/src/components/CreativePipelineBoard.tsx b/src/components/CreativePipelineBoard.tsx index 8b67f10..b1ecfee 100644 --- a/src/components/CreativePipelineBoard.tsx +++ b/src/components/CreativePipelineBoard.tsx @@ -19,12 +19,16 @@ import { } from 'lucide-react'; import { PageHeader, Surface } from '@/components/ui'; import { + isScheduledAtInPast, + nextLocalScheduleSlot, + normalizePipelineFormat, scheduleLabel, toDatetimeLocalValue, type PublishChannel, } from '@/lib/pipeline-schedule'; import { buildCaption, + isLocalScheduleInPast, mapFormatToPipeline, mapPipelineFormatToCalendar, scheduledAtFromPiece, @@ -245,14 +249,15 @@ export default function CreativePipelineBoard() { function applyCalendarPiece(id: string) { setCalPick(id); if (id === '__new__') { + const slot = nextLocalScheduleSlot(); setNewTitle(''); setNewCaption(''); setNewHook(''); setNewPillar(''); setNewFormat('post'); - setNewMonth(''); - setNewDate(''); - setNewTime('10:00'); + setNewMonth(slot.date.slice(0, 7)); + setNewDate(slot.date); + setNewTime(slot.time); clearMediaSelection(); return; } @@ -263,11 +268,27 @@ export default function CreativePipelineBoard() { setNewCaption(buildCaption(p) || p.body || ''); setNewPillar(p.pillar || ''); setNewFormat( - mapFormatToPipeline(p.format, p.kind) as PipelineItem['format'] + normalizePipelineFormat( + mapFormatToPipeline(p.format, p.kind) + ) as PipelineItem['format'] ); - setNewMonth(p.month || p.date.slice(0, 7)); - setNewDate(p.date.slice(0, 10)); - setNewTime((p.time || '10:00').slice(0, 5)); + const date = p.date.slice(0, 10); + let time = (p.time || '10:00').slice(0, 5); + if (isLocalScheduleInPast(date, time)) { + const slot = nextLocalScheduleSlot(); + time = slot.date === date ? slot.time : slot.time; + // If calendar day is already past entirely, keep the day but bump time; + // if still past (e.g. yesterday), move to next slot date. + if (isLocalScheduleInPast(date, time)) { + setNewMonth(slot.date.slice(0, 7)); + setNewDate(slot.date); + setNewTime(slot.time); + return; + } + } + setNewMonth(p.month || date.slice(0, 7)); + setNewDate(date); + setNewTime(time); } function resetForm() { @@ -339,6 +360,12 @@ export default function CreativePipelineBoard() { e.preventDefault(); if (!newTitle.trim()) return; setErr(null); + if (newDate && isLocalScheduleInPast(newDate, newTime || '10:00')) { + setErr( + 'No se puede programar en una fecha/hora ya pasada. Elige un horario futuro.' + ); + return; + } setCreating(true); try { @@ -348,12 +375,13 @@ export default function CreativePipelineBoard() { calendarPieceId && calendarPieceId !== '__new__' ? calPieces.find((p) => p.id === calendarPieceId) : null; + const resolvedFormat = normalizePipelineFormat(newFormat); if (syncCalendar) { const date = newDate || (newMonth ? `${newMonth}-01` : new Date().toISOString().slice(0, 10)); - const calFmt = mapPipelineFormatToCalendar(newFormat); + const calFmt = mapPipelineFormatToCalendar(resolvedFormat); const calRes = await fetch('/api/calendar/pieces', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, @@ -389,7 +417,7 @@ export default function CreativePipelineBoard() { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: newTitle.trim(), - format: newFormat, + format: resolvedFormat, month: newMonth || (newDate ? newDate.slice(0, 7) : null), caption: newCaption || null, notes: newPillar ? `Pilar: ${newPillar}` : null, @@ -403,7 +431,8 @@ export default function CreativePipelineBoard() { title: pickedCal.title, date: pickedCal.date, time: pickedCal.time, - format: pickedCal.format, + format: mapPipelineFormatToCalendar(resolvedFormat).format, + kind: mapPipelineFormatToCalendar(resolvedFormat).kind, pillar: pickedCal.pillar || newPillar, hook: newHook || pickedCal.hook, body: newCaption || pickedCal.body, @@ -415,6 +444,8 @@ export default function CreativePipelineBoard() { title: newTitle.trim(), date: newDate || null, time: newTime || null, + format: mapPipelineFormatToCalendar(resolvedFormat).format, + kind: mapPipelineFormatToCalendar(resolvedFormat).kind, pillar: newPillar || null, hook: newHook || null, body: newCaption || null, @@ -442,7 +473,7 @@ export default function CreativePipelineBoard() { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: newTitle.trim(), - format: newFormat, + format: resolvedFormat, month: newMonth || (newDate ? newDate.slice(0, 7) : null), caption: newCaption || null, notes: newPillar ? `Pilar: ${newPillar}` : null, @@ -556,12 +587,13 @@ export default function CreativePipelineBoard() { } } - /** Edit title/copy on an existing card; optionally sync RRSS calendar piece. */ + /** Edit title/copy/format on an existing card; optionally sync RRSS calendar piece. */ async function saveContent( id: string, payload: { title: string; caption: string; + format?: PipelineItem['format']; notes?: string | null; syncCalendar: boolean; calendarDate?: string | null; @@ -569,16 +601,31 @@ export default function CreativePipelineBoard() { } ) { if (!isAgency) return; + if ( + payload.calendarDate && + isLocalScheduleInPast( + payload.calendarDate, + payload.calendarTime || '10:00' + ) + ) { + setErr( + 'No se puede programar en una fecha/hora ya pasada. Elige un horario futuro.' + ); + return; + } setBusyId(id); setErr(null); try { const target = items.find((x) => x.id === id) || previewItem; + const resolvedFormat = normalizePipelineFormat( + payload.format || target?.format || 'post' + ); if ( payload.syncCalendar && target?.calendar_piece_id && payload.calendarDate ) { - const calFmt = mapPipelineFormatToCalendar(target.format || 'post'); + const calFmt = mapPipelineFormatToCalendar(resolvedFormat); const calRes = await fetch('/api/calendar/pieces', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, @@ -615,6 +662,7 @@ export default function CreativePipelineBoard() { id, title: payload.title.trim(), caption: payload.caption || null, + format: resolvedFormat, notes: payload.notes ?? undefined, ...(sched !== undefined ? { scheduled_at: sched } : {}), }), @@ -1019,8 +1067,13 @@ export default function CreativePipelineBoard() { type="date" value={newDate} onChange={(e) => { - setNewDate(e.target.value); - if (e.target.value) setNewMonth(e.target.value.slice(0, 7)); + const d = e.target.value; + setNewDate(d); + if (d) setNewMonth(d.slice(0, 7)); + if (d && isLocalScheduleInPast(d, newTime || '10:00')) { + const slot = nextLocalScheduleSlot(); + setNewTime(slot.date === d ? slot.time : slot.time); + } }} className="mt-1 w-full rounded-lg border border-gray-200 px-3 py-2 text-sm" /> @@ -1035,6 +1088,12 @@ export default function CreativePipelineBoard() { onChange={(e) => setNewTime(e.target.value)} className="mt-1 w-full rounded-lg border border-gray-200 px-3 py-2 text-sm" /> + {newDate && + isLocalScheduleInPast(newDate, newTime || '10:00') ? ( +

+ Ese horario ya ha pasado. +

+ ) : null}
+

{monthLabel(item.month)}{' '} {item.external_ref ? `· Ref: ${item.external_ref}` : ''} @@ -1978,7 +2090,15 @@ function DetailModal({ setEditDate(e.target.value)} + onChange={(e) => { + const d = e.target.value; + setEditDate(d); + if (d && isLocalScheduleInPast(d, editTime || '10:00')) { + const slot = nextLocalScheduleSlot(); + if (slot.date === d) setEditTime(slot.time); + else setEditTime(slot.time); + } + }} className="mt-0.5 w-full rounded-lg border border-gray-200 px-2 py-1 text-xs" /> @@ -1992,6 +2112,11 @@ function DetailModal({ />

+ {schedErr ? ( +

+ {schedErr} +

+ ) : null} {item.calendar_piece_id ? (