Files
4ventures-client-portal/src/app/api/calendar/move/route.ts
4Ventures 812052b7eb fix(publish): no saltar piezas programadas en revisión
El cron también publica revision_agencia/programada con horario vencido; mover en calendario deja la pieza en cola.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-16 15:55:12 +02:00

326 lines
8.6 KiB
TypeScript

import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { resolveActiveTenant } from '@/lib/tenant-context';
import {
isAgencyRole,
serviceHeaders,
supabaseUrl,
userRole,
} from '@/lib/agency';
import type { RrssPiece } from '@/components/RrssMonthView';
import { emptyMonthPlan } from '@/lib/calendar-documents';
export const dynamic = 'force-dynamic';
export const runtime = 'nodejs';
type PlanDoc = {
id: string;
title: string;
content: unknown;
};
function asObject(content: unknown): Record<string, unknown> | null {
let c = content;
if (typeof c === 'string') {
try {
c = JSON.parse(c);
} catch {
return null;
}
}
if (!c || typeof c !== 'object') return null;
return c as Record<string, unknown>;
}
function piecesOf(content: Record<string, unknown>): RrssPiece[] {
return Array.isArray(content.pieces) ? (content.pieces as RrssPiece[]) : [];
}
function monthTitle(ym: string): string {
const labels = [
'Enero',
'Febrero',
'Marzo',
'Abril',
'Mayo',
'Junio',
'Julio',
'Agosto',
'Septiembre',
'Octubre',
'Noviembre',
'Diciembre',
];
const m = Number(ym.slice(5, 7)) - 1;
return `RRSS ${labels[m] || ym} ${ym.slice(0, 4)}`;
}
async function patchDoc(
base: string,
headers: Record<string, string>,
id: string,
plan: Record<string, unknown>
) {
return fetch(`${base}/rest/v1/documents?id=eq.${encodeURIComponent(id)}`, {
method: 'PATCH',
headers: {
...headers,
'Content-Type': 'application/json',
Prefer: 'return=minimal',
},
body: JSON.stringify({
content: plan,
updated_at: new Date().toISOString(),
}),
});
}
/** Keep creative_pipeline.scheduled_at in sync so overlays don't snap back. */
async function syncPipelineSchedule(
base: string,
headers: Record<string, string>,
tenantId: string,
pieceId: string,
date: string,
time?: string
) {
const hhmm = (time || '10:00').slice(0, 5);
const scheduledAt = new Date(`${date}T${hhmm}:00`).toISOString();
const pipeId = pieceId.startsWith('pipe-') ? pieceId.slice(5) : null;
const filters = pipeId
? `id=eq.${encodeURIComponent(pipeId)}`
: `calendar_piece_id=eq.${encodeURIComponent(pieceId)}`;
await fetch(
`${base}/rest/v1/creative_pipeline?tenant_id=eq.${encodeURIComponent(
tenantId
)}&${filters}`,
{
method: 'PATCH',
headers: {
...headers,
'Content-Type': 'application/json',
Prefer: 'return=minimal',
},
body: JSON.stringify({
scheduled_at: scheduledAt,
// Mover en calendario = reprogramar: el cron debe poder publicarla
status: 'aprobada',
publish_status: 'queued',
publish_error: null,
updated_at: new Date().toISOString(),
}),
}
);
}
export async function PATCH(req: NextRequest) {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const agency = isAgencyRole(userRole(user));
if (!agency) {
return NextResponse.json(
{ error: 'Solo la agencia puede mover piezas' },
{ status: 403 }
);
}
const ctx = await resolveActiveTenant(user);
if (!ctx.tenantId || ctx.portfolioWide) {
return NextResponse.json(
{ error: 'Selecciona un cliente en Contexto.' },
{ status: 400 }
);
}
const body = (await req.json().catch(() => null)) as {
pieceId?: string;
date?: string;
snapshot?: RrssPiece;
} | null;
const pieceId = String(body?.pieceId || '').trim();
const date = String(body?.date || '').slice(0, 10);
if (!pieceId || !/^\d{4}-\d{2}-\d{2}$/.test(date)) {
return NextResponse.json(
{ error: 'Falta pieceId o una fecha válida.' },
{ status: 400 }
);
}
const headers = serviceHeaders();
const base = supabaseUrl();
if (!headers || !base) {
return NextResponse.json({ error: 'Service role required' }, { status: 503 });
}
const list = await fetch(
`${base}/rest/v1/documents?tenant_id=eq.${encodeURIComponent(
ctx.tenantId
)}&type=eq.calendar&select=id,title,content`,
{ headers, cache: 'no-store' }
);
const docs = (list.ok ? await list.json() : []) as PlanDoc[];
let source: { doc: PlanDoc; plan: Record<string, unknown>; index: number } | null =
null;
for (const doc of docs) {
const plan = asObject(doc.content);
if (!plan) continue;
const pieces = piecesOf(plan);
const index = pieces.findIndex((p) => p.id === pieceId);
if (index >= 0) {
source = { doc, plan, index };
break;
}
}
const ym = date.slice(0, 7);
let targetDoc: PlanDoc | null = source?.doc || null;
let targetPlan: Record<string, unknown> | null = source?.plan || null;
const sourceMonth = source ? String(source.plan.month || '') : '';
// Same-document move when the piece already lives in the target month doc
// (also covers old imports where plan.month was missing).
if (
source &&
(sourceMonth === ym ||
(!sourceMonth &&
piecesOf(source.plan).some((p) =>
String(p.date || '').startsWith(ym)
)))
) {
targetDoc = source.doc;
targetPlan = source.plan;
if (!targetPlan.month) targetPlan.month = ym;
} else if (!targetDoc || sourceMonth !== ym) {
targetDoc = null;
targetPlan = null;
for (const doc of docs) {
const plan = asObject(doc.content);
if (!plan) continue;
if (String(plan.month || '') === ym) {
targetDoc = doc;
targetPlan = plan;
break;
}
}
}
// Auto-create month doc when dragging into a month that has no calendar yet.
if (!targetDoc || !targetPlan) {
const plan = emptyMonthPlan(ym, ctx.label || 'Cliente');
const create = await fetch(`${base}/rest/v1/documents`, {
method: 'POST',
headers: {
...headers,
'Content-Type': 'application/json',
Prefer: 'return=representation',
},
body: JSON.stringify({
tenant_id: ctx.tenantId,
type: 'calendar',
title: monthTitle(ym),
content: plan,
}),
});
if (!create.ok) {
return NextResponse.json(
{
error:
'No hay calendario para ese mes y no se pudo crear uno nuevo.',
},
{ status: 404 }
);
}
const created = (await create.json()) as PlanDoc[];
targetDoc = Array.isArray(created) ? created[0] : created;
targetPlan = plan as unknown as Record<string, unknown>;
if (!targetDoc?.id) {
return NextResponse.json(
{ error: 'No hay un calendario guardado para ese mes.' },
{ status: 404 }
);
}
}
const targetPieces = piecesOf(targetPlan);
const movedTime =
source?.plan
? piecesOf(source.plan)[source.index]?.time
: body?.snapshot?.time;
if (source && source.doc.id === targetDoc.id) {
targetPieces[source.index] = {
...targetPieces[source.index],
date,
platform: String(targetPieces[source.index].platform || 'instagram')
.trim()
.toLowerCase(),
};
} else if (source) {
const moved = {
...piecesOf(source.plan)[source.index],
date,
platform: String(
piecesOf(source.plan)[source.index]?.platform || 'instagram'
)
.trim()
.toLowerCase(),
};
piecesOf(source.plan).splice(source.index, 1);
source.plan.pieces = piecesOf(source.plan);
if (!source.plan.month && sourceMonth) source.plan.month = sourceMonth;
const srcPatch = await patchDoc(base, headers, source.doc.id, source.plan);
if (!srcPatch.ok) {
return NextResponse.json(
{ error: 'No se pudo actualizar el mes de origen.' },
{ status: 500 }
);
}
targetPieces.push(moved);
} else if (body?.snapshot) {
targetPieces.push({
...body.snapshot,
date,
id: pieceId,
platform: String(body.snapshot.platform || 'instagram')
.trim()
.toLowerCase(),
});
} else {
return NextResponse.json(
{ error: 'No se encontró esa pieza en el calendario.' },
{ status: 404 }
);
}
targetPlan.month = ym;
targetPlan.pieces = targetPieces.sort((a, b) =>
String(a.date).localeCompare(String(b.date))
);
const patch = await patchDoc(base, headers, targetDoc.id, targetPlan);
if (!patch.ok) {
return NextResponse.json(
{ error: (await patch.text()).slice(0, 240) || 'No se pudo guardar.' },
{ status: 500 }
);
}
await syncPipelineSchedule(
base,
headers,
ctx.tenantId,
pieceId,
date,
movedTime
);
return NextResponse.json({ ok: true, pieceId, date });
}