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:
404
src/app/api/calendar/month/route.ts
Normal file
404
src/app/api/calendar/month/route.ts
Normal 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),
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user