/** * 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, 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, 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, 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, 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 = { 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; 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; 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), }); }