Removes the calendar document and linked draft pipeline pieces, with confirm-by-typing the YYYY-MM code. Co-authored-by: Cursor <cursoragent@cursor.com>
486 lines
14 KiB
TypeScript
486 lines
14 KiB
TypeScript
/**
|
|
* Create / update editorial month plans and pieces.
|
|
* POST { action: 'create'|'upsert_piece'|'delete_piece'|'replace_plan'|'delete_month', month, piece?, plan?, syncPipeline? }
|
|
*/
|
|
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 {
|
|
asPlanObject,
|
|
emptyMonthPlan,
|
|
newPieceId,
|
|
buildCaption,
|
|
mapFormatToPipeline,
|
|
scheduledAtFromPiece,
|
|
type CalendarDoc,
|
|
} from '@/lib/calendar-documents';
|
|
import { monthLabel } from '@/lib/calendar-import';
|
|
import { sanitizePublishCaption } from '@/lib/publish-caption';
|
|
import { enrichPieceFromCatalog } from '@/lib/calendar-template';
|
|
import type { RrssMonthPlan, RrssPiece } from '@/components/RrssMonthView';
|
|
|
|
export const dynamic = 'force-dynamic';
|
|
|
|
function str(v: unknown, max = 4000): string {
|
|
return typeof v === 'string' ? v.trim().slice(0, max) : '';
|
|
}
|
|
|
|
async function loadMonthDoc(
|
|
base: string,
|
|
headers: Record<string, string>,
|
|
tenantId: string,
|
|
month: string
|
|
): Promise<{ doc: CalendarDoc | null; plan: RrssMonthPlan | null }> {
|
|
const r = await fetch(
|
|
`${base}/rest/v1/documents?tenant_id=eq.${encodeURIComponent(
|
|
tenantId
|
|
)}&type=eq.calendar&select=id,title,content`,
|
|
{ headers, cache: 'no-store' }
|
|
);
|
|
if (!r.ok) return { doc: null, plan: null };
|
|
const docs = (await r.json()) as CalendarDoc[];
|
|
for (const d of docs) {
|
|
const plan = asPlanObject(d.content) as RrssMonthPlan | null;
|
|
if (plan && plan.month === month) {
|
|
return { doc: d, plan: plan as unknown as RrssMonthPlan };
|
|
}
|
|
}
|
|
return { doc: null, plan: null };
|
|
}
|
|
|
|
async function savePlan(
|
|
base: string,
|
|
headers: Record<string, string>,
|
|
tenantId: string,
|
|
plan: RrssMonthPlan,
|
|
existingId?: string | null
|
|
) {
|
|
const title = `RRSS ${plan.label || monthLabel(plan.month)}`;
|
|
if (existingId) {
|
|
const patch = await fetch(
|
|
`${base}/rest/v1/documents?id=eq.${encodeURIComponent(existingId)}`,
|
|
{
|
|
method: 'PATCH',
|
|
headers: {
|
|
...headers,
|
|
'Content-Type': 'application/json',
|
|
Prefer: 'return=representation',
|
|
},
|
|
body: JSON.stringify({
|
|
title,
|
|
content: plan,
|
|
status: 'approved',
|
|
updated_at: new Date().toISOString(),
|
|
}),
|
|
}
|
|
);
|
|
if (!patch.ok) throw new Error(await patch.text());
|
|
const rows = await patch.json();
|
|
return Array.isArray(rows) ? rows[0] : rows;
|
|
}
|
|
const ins = await fetch(`${base}/rest/v1/documents`, {
|
|
method: 'POST',
|
|
headers: {
|
|
...headers,
|
|
'Content-Type': 'application/json',
|
|
Prefer: 'return=representation',
|
|
},
|
|
body: JSON.stringify({
|
|
tenant_id: tenantId,
|
|
type: 'calendar',
|
|
title,
|
|
content: plan,
|
|
status: 'approved',
|
|
}),
|
|
});
|
|
if (!ins.ok) throw new Error(await ins.text());
|
|
const rows = await ins.json();
|
|
return Array.isArray(rows) ? rows[0] : rows;
|
|
}
|
|
|
|
function normalizePiece(
|
|
raw: Record<string, unknown>,
|
|
fallbackDate: string
|
|
): RrssPiece {
|
|
const date = str(raw.date, 10) || fallbackDate;
|
|
const title = str(raw.title, 160) || 'Sin título';
|
|
const format = str(raw.format, 40) || 'Post';
|
|
const kindRaw = str(raw.kind, 20);
|
|
const kind =
|
|
kindRaw ||
|
|
(/stor/i.test(format) ? 'story' : /reel/i.test(format) ? 'reel' : 'post');
|
|
const cleanBody = sanitizePublishCaption(
|
|
str(raw.body, 4000) || title,
|
|
{ maxLen: 4000 }
|
|
).caption;
|
|
const enriched = enrichPieceFromCatalog({
|
|
title,
|
|
platform: str(raw.platform, 40) || 'instagram',
|
|
format,
|
|
pillar: str(raw.pillar, 80),
|
|
time: str(raw.time, 5),
|
|
cta: str(raw.cta, 200),
|
|
hashtags: str(raw.hashtags, 500),
|
|
hook: str(raw.hook, 300),
|
|
body: cleanBody,
|
|
});
|
|
return {
|
|
id: str(raw.id, 120) || newPieceId(date, title),
|
|
date,
|
|
time: enriched.time || '10:30',
|
|
platform: (enriched.platform || 'instagram').toLowerCase(),
|
|
format: enriched.format || format,
|
|
pillar: enriched.pillar || undefined,
|
|
title: enriched.title,
|
|
hook: enriched.hook || enriched.title,
|
|
body: enriched.body || enriched.title,
|
|
cta: enriched.cta || undefined,
|
|
hashtags: enriched.hashtags || undefined,
|
|
objective_metric: str(raw.objective_metric || raw.objetivo, 200) || undefined,
|
|
status: str(raw.status, 40) || 'planned',
|
|
kind,
|
|
color:
|
|
kind === 'story'
|
|
? '#374151'
|
|
: /linkedin/i.test(enriched.platform || '')
|
|
? '#7C66B8'
|
|
: '#2071cf',
|
|
};
|
|
}
|
|
|
|
async function syncPieceToPipeline(
|
|
base: string,
|
|
headers: Record<string, string>,
|
|
tenantId: string,
|
|
userId: string,
|
|
piece: RrssPiece,
|
|
month: string
|
|
) {
|
|
const caption = sanitizePublishCaption(
|
|
buildCaption(piece) || piece.title,
|
|
{ maxLen: 4000 }
|
|
).caption;
|
|
// Find existing by calendar_piece_id
|
|
const find = await fetch(
|
|
`${base}/rest/v1/creative_pipeline?tenant_id=eq.${encodeURIComponent(
|
|
tenantId
|
|
)}&calendar_piece_id=eq.${encodeURIComponent(piece.id)}&select=id&limit=1`,
|
|
{ headers, cache: 'no-store' }
|
|
);
|
|
const existing = find.ok ? ((await find.json()) as { id: string }[]) : [];
|
|
const row: Record<string, unknown> = {
|
|
title: piece.title,
|
|
format: mapFormatToPipeline(piece.format, piece.kind),
|
|
month,
|
|
caption: caption || null,
|
|
notes: piece.pillar ? `Pilar: ${piece.pillar}` : null,
|
|
calendar_piece_id: piece.id,
|
|
scheduled_at: scheduledAtFromPiece(piece),
|
|
channels:
|
|
(piece.platform || '').toLowerCase() === 'facebook'
|
|
? ['facebook', 'instagram']
|
|
: ['instagram', 'facebook'],
|
|
updated_at: new Date().toISOString(),
|
|
};
|
|
if (existing[0]?.id) {
|
|
await fetch(
|
|
`${base}/rest/v1/creative_pipeline?id=eq.${encodeURIComponent(existing[0].id)}`,
|
|
{
|
|
method: 'PATCH',
|
|
headers: {
|
|
...headers,
|
|
'Content-Type': 'application/json',
|
|
Prefer: 'return=minimal',
|
|
},
|
|
body: JSON.stringify(row),
|
|
}
|
|
);
|
|
return existing[0].id;
|
|
}
|
|
const ins = await fetch(`${base}/rest/v1/creative_pipeline`, {
|
|
method: 'POST',
|
|
headers: {
|
|
...headers,
|
|
'Content-Type': 'application/json',
|
|
Prefer: 'return=representation',
|
|
},
|
|
body: JSON.stringify({
|
|
...row,
|
|
tenant_id: tenantId,
|
|
status: 'borrador',
|
|
publish_status: 'idle',
|
|
created_by: userId,
|
|
}),
|
|
});
|
|
if (!ins.ok) return null;
|
|
const rows = (await ins.json()) as { id: string }[];
|
|
return rows[0]?.id || null;
|
|
}
|
|
|
|
export async function POST(req: NextRequest) {
|
|
const supabase = await createClient();
|
|
const {
|
|
data: { user },
|
|
} = await supabase.auth.getUser();
|
|
if (!user) {
|
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
}
|
|
if (!isAgencyRole(userRole(user))) {
|
|
return NextResponse.json({ error: 'Solo agencia' }, { status: 403 });
|
|
}
|
|
const ctx = await resolveActiveTenant(user);
|
|
if (!ctx.tenantId || ctx.portfolioWide) {
|
|
return NextResponse.json(
|
|
{ error: 'Selecciona un cliente en Contexto.' },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
const headers = serviceHeaders();
|
|
const base = supabaseUrl();
|
|
if (!headers || !base) {
|
|
return NextResponse.json({ error: 'Service role required' }, { status: 503 });
|
|
}
|
|
|
|
const body = (await req.json().catch(() => ({}))) as Record<string, unknown>;
|
|
const action = str(body.action, 40) || 'create';
|
|
const month = str(body.month, 7);
|
|
if (!/^\d{4}-\d{2}$/.test(month)) {
|
|
return NextResponse.json({ error: 'month YYYY-MM requerido' }, { status: 400 });
|
|
}
|
|
|
|
const { doc, plan: existing } = await loadMonthDoc(
|
|
base,
|
|
headers,
|
|
ctx.tenantId,
|
|
month
|
|
);
|
|
|
|
try {
|
|
if (action === 'create') {
|
|
if (existing && !(body.force === true)) {
|
|
return NextResponse.json({
|
|
ok: true,
|
|
existed: true,
|
|
plan: existing,
|
|
document_id: doc?.id,
|
|
});
|
|
}
|
|
const seed = body.plan as RrssMonthPlan | undefined;
|
|
const plan =
|
|
seed && seed.month === month
|
|
? seed
|
|
: emptyMonthPlan(month, ctx.label);
|
|
const saved = await savePlan(
|
|
base,
|
|
headers,
|
|
ctx.tenantId,
|
|
plan,
|
|
body.force === true ? doc?.id : null
|
|
);
|
|
return NextResponse.json({
|
|
ok: true,
|
|
created: true,
|
|
plan,
|
|
document_id: saved?.id,
|
|
});
|
|
}
|
|
|
|
if (action === 'replace_plan') {
|
|
const seed = body.plan as RrssMonthPlan | undefined;
|
|
if (!seed || seed.month !== month) {
|
|
return NextResponse.json({ error: 'plan inválido' }, { status: 400 });
|
|
}
|
|
const saved = await savePlan(base, headers, ctx.tenantId, seed, doc?.id);
|
|
return NextResponse.json({
|
|
ok: true,
|
|
plan: seed,
|
|
document_id: saved?.id,
|
|
});
|
|
}
|
|
|
|
if (action === 'upsert_piece') {
|
|
const raw = (body.piece || {}) as Record<string, unknown>;
|
|
const piece = normalizePiece(raw, `${month}-01`);
|
|
if (!piece.date.startsWith(month)) {
|
|
return NextResponse.json(
|
|
{ error: 'La fecha debe pertenecer al mes' },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
let plan = existing || emptyMonthPlan(month, ctx.label);
|
|
const idx = plan.pieces.findIndex((p) => p.id === piece.id);
|
|
if (idx >= 0) plan.pieces[idx] = { ...plan.pieces[idx], ...piece };
|
|
else plan.pieces.push(piece);
|
|
plan.pieces.sort((a, b) => a.date.localeCompare(b.date));
|
|
plan.hero = {
|
|
...plan.hero,
|
|
chips: [`${plan.pieces.length} piezas`],
|
|
};
|
|
const saved = await savePlan(base, headers, ctx.tenantId, plan, doc?.id);
|
|
let pipelineId: string | null = null;
|
|
if (body.syncPipeline !== false) {
|
|
pipelineId = await syncPieceToPipeline(
|
|
base,
|
|
headers,
|
|
ctx.tenantId,
|
|
user.id,
|
|
piece,
|
|
month
|
|
);
|
|
}
|
|
return NextResponse.json({
|
|
ok: true,
|
|
piece,
|
|
plan,
|
|
document_id: saved?.id,
|
|
pipeline_id: pipelineId,
|
|
});
|
|
}
|
|
|
|
if (action === 'delete_piece') {
|
|
const pieceId = str(body.pieceId || (body.piece as { id?: string })?.id, 120);
|
|
if (!pieceId || !existing) {
|
|
return NextResponse.json({ error: 'pieza no encontrada' }, { status: 404 });
|
|
}
|
|
const plan = {
|
|
...existing,
|
|
pieces: existing.pieces.filter((p) => p.id !== pieceId),
|
|
};
|
|
plan.hero = {
|
|
...plan.hero,
|
|
chips: [`${plan.pieces.length} piezas`],
|
|
};
|
|
const saved = await savePlan(base, headers, ctx.tenantId, plan, doc?.id);
|
|
return NextResponse.json({
|
|
ok: true,
|
|
plan,
|
|
document_id: saved?.id,
|
|
});
|
|
}
|
|
|
|
if (action === 'delete_month') {
|
|
if (!doc?.id) {
|
|
return NextResponse.json(
|
|
{ error: 'No hay calendario guardado para ese mes' },
|
|
{ status: 404 }
|
|
);
|
|
}
|
|
const pieceIds = (existing?.pieces || [])
|
|
.map((p) => p.id)
|
|
.filter((id): id is string => Boolean(id));
|
|
|
|
let pipelineDeleted = 0;
|
|
let pipelineUnlinked = 0;
|
|
|
|
for (const pieceId of pieceIds) {
|
|
const list = await fetch(
|
|
`${base}/rest/v1/creative_pipeline?tenant_id=eq.${encodeURIComponent(
|
|
ctx.tenantId
|
|
)}&calendar_piece_id=eq.${encodeURIComponent(
|
|
pieceId
|
|
)}&select=id,status,publish_status`,
|
|
{ headers, cache: 'no-store' }
|
|
);
|
|
if (!list.ok) continue;
|
|
const rows = (await list.json()) as {
|
|
id: string;
|
|
status?: string;
|
|
publish_status?: string;
|
|
}[];
|
|
for (const row of rows) {
|
|
const published =
|
|
row.publish_status === 'published' ||
|
|
row.status === 'publicada' ||
|
|
row.status === 'publicado';
|
|
if (published) {
|
|
const patch = await fetch(
|
|
`${base}/rest/v1/creative_pipeline?id=eq.${encodeURIComponent(row.id)}`,
|
|
{
|
|
method: 'PATCH',
|
|
headers: {
|
|
...headers,
|
|
'Content-Type': 'application/json',
|
|
Prefer: 'return=minimal',
|
|
},
|
|
body: JSON.stringify({
|
|
calendar_piece_id: null,
|
|
updated_at: new Date().toISOString(),
|
|
}),
|
|
}
|
|
);
|
|
if (patch.ok) pipelineUnlinked += 1;
|
|
} else {
|
|
const del = await fetch(
|
|
`${base}/rest/v1/creative_pipeline?id=eq.${encodeURIComponent(row.id)}`,
|
|
{ method: 'DELETE', headers }
|
|
);
|
|
if (del.ok) pipelineDeleted += 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
const delDoc = await fetch(
|
|
`${base}/rest/v1/documents?id=eq.${encodeURIComponent(doc.id)}&tenant_id=eq.${encodeURIComponent(ctx.tenantId)}`,
|
|
{ method: 'DELETE', headers }
|
|
);
|
|
if (!delDoc.ok) {
|
|
throw new Error(
|
|
(await delDoc.text()).slice(0, 300) || 'No se pudo borrar el documento'
|
|
);
|
|
}
|
|
|
|
return NextResponse.json({
|
|
ok: true,
|
|
deleted: true,
|
|
month,
|
|
document_id: doc.id,
|
|
pipeline_deleted: pipelineDeleted,
|
|
pipeline_unlinked: pipelineUnlinked,
|
|
});
|
|
}
|
|
|
|
return NextResponse.json({ error: 'action desconocida' }, { status: 400 });
|
|
} catch (e) {
|
|
return NextResponse.json(
|
|
{ error: e instanceof Error ? e.message.slice(0, 400) : 'Error' },
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
}
|
|
|
|
export async function GET(req: NextRequest) {
|
|
const supabase = await createClient();
|
|
const {
|
|
data: { user },
|
|
} = await supabase.auth.getUser();
|
|
if (!user) {
|
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
}
|
|
const ctx = await resolveActiveTenant(user);
|
|
if (!ctx.tenantId || ctx.portfolioWide) {
|
|
return NextResponse.json({ error: 'Selecciona un cliente' }, { status: 400 });
|
|
}
|
|
const headers = serviceHeaders();
|
|
const base = supabaseUrl();
|
|
if (!headers || !base) {
|
|
return NextResponse.json({ error: 'Service role required' }, { status: 503 });
|
|
}
|
|
const month = req.nextUrl.searchParams.get('month') || '';
|
|
if (!/^\d{4}-\d{2}$/.test(month)) {
|
|
return NextResponse.json({ error: 'month requerido' }, { status: 400 });
|
|
}
|
|
const { doc, plan } = await loadMonthDoc(base, headers, ctx.tenantId, month);
|
|
return NextResponse.json({
|
|
ok: true,
|
|
plan,
|
|
document_id: doc?.id || null,
|
|
exists: Boolean(plan),
|
|
});
|
|
}
|