Add in-app editorial calendar creator with month zoom and preview.

Agency can pick a month, generate a brand/seasonality preview, and add/edit/delete pieces with the same fields as the creative pipeline, optionally syncing drafts there.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
4Ventures
2026-10-08 18:12:10 +02:00
parent 501f8203e8
commit f316497650
7 changed files with 1700 additions and 8 deletions

View File

@@ -0,0 +1,404 @@
/**
* Create / update editorial month plans and pieces.
* POST { action: 'create'|'upsert_piece'|'delete_piece'|'replace_plan', 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: 'propuesta',
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,
});
}
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),
});
}