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>
326 lines
8.6 KiB
TypeScript
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 });
|
|
}
|