fix(pipeline): editar formato Post/Story y bloquear horarios pasados

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 <cursoragent@cursor.com>
This commit is contained in:
4Ventures
2026-10-16 13:05:30 +02:00
parent c434fa5834
commit 77fd628078
6 changed files with 270 additions and 37 deletions

View File

@@ -189,7 +189,7 @@ async function syncPieceToPipeline(
const existing = find.ok ? ((await find.json()) as { id: string }[]) : [];
const row: Record<string, unknown> = {
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,

View File

@@ -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'],

View File

@@ -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<string, unknown> = {
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.

View File

@@ -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') ? (
<p className="mt-1 text-[11px] text-red-600">
Ese horario ya ha pasado.
</p>
) : null}
</div>
<div>
<label className="block text-xs font-medium text-gray-600">
@@ -1685,6 +1744,7 @@ function DetailModal({
onSaveContent: (payload: {
title: string;
caption: string;
format?: PipelineItem['format'];
notes?: string | null;
syncCalendar: boolean;
calendarDate?: string | null;
@@ -1718,14 +1778,21 @@ function DetailModal({
);
const [editTitle, setEditTitle] = useState(item.title);
const [editCaption, setEditCaption] = useState(item.caption || '');
const [editFormat, setEditFormat] = useState<PipelineItem['format']>(
normalizePipelineFormat(item.format)
);
const [syncCal, setSyncCal] = useState(Boolean(item.calendar_piece_id));
const [schedErr, setSchedErr] = useState<string | null>(null);
const [editDate, setEditDate] = useState(
(item.scheduled_at || item.created_at || '').slice(0, 10)
);
const [editTime, setEditTime] = useState(() => {
if (!item.scheduled_at) return '10:00';
if (!item.scheduled_at) {
const slot = nextLocalScheduleSlot();
return slot.time;
}
const d = new Date(item.scheduled_at);
if (Number.isNaN(d.getTime())) return '10:00';
if (Number.isNaN(d.getTime())) return nextLocalScheduleSlot().time;
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
});
@@ -1741,7 +1808,9 @@ function DetailModal({
setChLi(ch.includes('linkedin'));
setEditTitle(item.title);
setEditCaption(item.caption || '');
setEditFormat(normalizePipelineFormat(item.format));
setSyncCal(Boolean(item.calendar_piece_id));
setSchedErr(null);
setEditDate((item.scheduled_at || item.created_at || '').slice(0, 10));
if (item.scheduled_at) {
const d = new Date(item.scheduled_at);
@@ -1751,13 +1820,13 @@ function DetailModal({
);
}
}
}, [item.id, item.scheduled_at, item.channels, item.title, item.caption, item.calendar_piece_id, item.created_at]);
}, [item.id, item.scheduled_at, item.channels, item.title, item.caption, item.format, item.calendar_piece_id, item.created_at]);
const activePath = allImages[activeIdx] || item.cover_path;
const currentUrl = mediaUrl(activePath);
const igUrl = getInstagramUrl(item.notes);
const formatInfo = FORMAT_CONFIG[item.format] || {
label: item.format,
const formatInfo = FORMAT_CONFIG[editFormat] || FORMAT_CONFIG[item.format] || {
label: editFormat || item.format,
badgeCls: 'bg-gray-100 text-gray-700 border-gray-200',
};
@@ -1765,6 +1834,7 @@ function DetailModal({
const clientCanDecide = !isAgency && clientCanDecideStatus(status);
function submitSchedule() {
setSchedErr(null);
const channels: PublishChannel[] = [];
if (chIg) channels.push('instagram');
if (chFb) channels.push('facebook');
@@ -1779,6 +1849,20 @@ function DetailModal({
: [igIso, fbIso]
.filter(Boolean)
.sort()[0] || null;
if (primary && isScheduledAtInPast(primary)) {
setSchedErr(
'No se puede programar en una fecha/hora ya pasada. Elige un horario futuro.'
);
return;
}
if (igIso && isScheduledAtInPast(igIso)) {
setSchedErr('El horario de Instagram ya ha pasado.');
return;
}
if (fbIso && isScheduledAtInPast(fbIso)) {
setSchedErr('El horario de Facebook ya ha pasado.');
return;
}
onSaveSchedule({
scheduled_at: primary,
channels: channels.length ? channels : ['instagram'],
@@ -1801,9 +1885,20 @@ function DetailModal({
function submitContent() {
if (!editTitle.trim()) return;
setSchedErr(null);
if (
editDate &&
isLocalScheduleInPast(editDate, editTime || '10:00')
) {
setSchedErr(
'No se puede programar en una fecha/hora ya pasada. Elige un horario futuro.'
);
return;
}
onSaveContent({
title: editTitle.trim(),
caption: editCaption,
format: editFormat,
syncCalendar: syncCal && Boolean(item.calendar_piece_id),
calendarDate: editDate || null,
calendarTime: editTime || '10:00',
@@ -1923,6 +2018,23 @@ function DetailModal({
className="mt-1 w-full rounded-lg border border-gray-200 px-2 py-1.5 text-sm font-semibold text-gray-900"
/>
</label>
<label className="block text-xs font-semibold text-gray-600">
Formato
<select
value={editFormat}
onChange={(e) =>
setEditFormat(
e.target.value as PipelineItem['format']
)
}
className="mt-1 w-full rounded-lg border border-gray-200 px-2 py-1.5 text-sm text-gray-900"
>
<option value="post">Post</option>
<option value="story">Story</option>
<option value="reel">Reel</option>
<option value="carrusel">Carrusel</option>
</select>
</label>
<p className="text-xs text-gray-500">
{monthLabel(item.month)}{' '}
{item.external_ref ? `· Ref: ${item.external_ref}` : ''}
@@ -1978,7 +2090,15 @@ function DetailModal({
<input
type="date"
value={editDate}
onChange={(e) => 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"
/>
</label>
@@ -1992,6 +2112,11 @@ function DetailModal({
/>
</label>
</div>
{schedErr ? (
<p className="text-[11px] text-red-700 bg-red-50 border border-red-200 rounded-lg px-2 py-1.5">
{schedErr}
</p>
) : null}
{item.calendar_piece_id ? (
<label className="flex items-start gap-2 text-[11px] text-gray-700">
<input
@@ -2152,6 +2277,11 @@ function DetailModal({
{item.publish_error}
</p>
) : null}
{schedErr ? (
<p className="text-[11px] text-red-700 bg-red-50 border border-red-200 rounded-lg px-2 py-1.5">
{schedErr}
</p>
) : null}
<div className="flex flex-col gap-2">
<button
type="button"

View File

@@ -58,9 +58,10 @@ export function piecesFromDocs(docs: CalendarDoc[]): CalendarPieceRow[] {
export function mapFormatToPipeline(format?: string, kind?: string): string {
const hay = `${format || ''} ${kind || ''}`.toLowerCase();
if (hay.includes('stor')) return 'story';
if (hay.includes('reel')) return 'reel';
if (hay.includes('carrusel') || hay.includes('carousel')) return 'carrusel';
// Match Stories / Story / historia (substring "stor") and explicit kind
if (/stor|historia/.test(hay) || kind === 'story') return 'story';
if (/reel/.test(hay) || kind === 'reel') return 'reel';
if (/carrusel|carousel/.test(hay)) return 'carrusel';
return 'post';
}
@@ -90,6 +91,17 @@ export function scheduledAtFromPiece(piece: Pick<RrssPiece, 'date' | 'time'>): s
return iso.toISOString();
}
/** True if local date+time is already past (for form validation). */
export function isLocalScheduleInPast(
date: string,
time: string,
graceMs = 60_000
): boolean {
const iso = scheduledAtFromPiece({ date, time });
if (!iso) return false;
return Date.parse(iso) < Date.now() - graceMs;
}
export function newPieceId(date: string, title: string): string {
return `${date}-${slugify(title || 'pieza')}-${Math.random().toString(36).slice(2, 7)}`;
}

View File

@@ -44,6 +44,64 @@ export function parseScheduledAt(raw: unknown): string | null | undefined {
return d.toISOString();
}
/** Grace for clock skew when rejecting schedules in the past. */
export const SCHEDULE_PAST_GRACE_MS = 60_000;
/** True if ISO timestamp is already in the past (beyond grace). */
export function isScheduledAtInPast(
iso: string | null | undefined,
graceMs = SCHEDULE_PAST_GRACE_MS
): boolean {
if (!iso) return false;
const t = Date.parse(iso);
if (Number.isNaN(t)) return false;
return t < Date.now() - graceMs;
}
/** Next local date+time slot at least `minAheadMin` minutes from now, rounded up. */
export function nextLocalScheduleSlot(minAheadMin = 15): {
date: string;
time: string;
} {
const d = new Date(Date.now() + minAheadMin * 60_000);
const step = 15;
const mins = d.getMinutes();
const rounded = Math.ceil(mins / step) * step;
if (rounded >= 60) {
d.setHours(d.getHours() + 1, 0, 0, 0);
} else {
d.setMinutes(rounded, 0, 0);
}
const pad = (n: number) => String(n).padStart(2, '0');
return {
date: `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`,
time: `${pad(d.getHours())}:${pad(d.getMinutes())}`,
};
}
export const PIPELINE_FORMATS = [
'post',
'story',
'reel',
'carrusel',
] as const;
export type PipelineFormat = (typeof PIPELINE_FORMATS)[number];
/** Normalize Stories/Story/carousel/etc. → canonical pipeline format. */
export function normalizePipelineFormat(
raw: unknown,
kind?: string | null
): PipelineFormat {
const hay = `${String(raw || '')} ${String(kind || '')}`.toLowerCase().trim();
if (/stor|historia/.test(hay)) return 'story';
if (/reel/.test(hay)) return 'reel';
if (/carrusel|carousel/.test(hay)) return 'carrusel';
if ((PIPELINE_FORMATS as readonly string[]).includes(hay)) {
return hay as PipelineFormat;
}
return 'post';
}
export function toDatetimeLocalValue(iso?: string | null): string {
if (!iso) return '';
const d = new Date(iso);