/** * POST { month: 'YYYY-MM' } → editorial preview for active tenant. */ import { NextRequest, NextResponse } from 'next/server'; import { createClient } from '@/lib/supabase/server'; import { resolveActiveTenant, restGet } from '@/lib/tenant-context'; import { isAgencyRole, userRole } from '@/lib/agency'; import { asPlanObject } from '@/lib/calendar-documents'; import { generateMonthPreview } from '@/lib/calendar-generate'; import type { RrssMonthPlan } from '@/components/RrssMonthView'; import { BRANDBOOK_DOC_TYPE, parseBrandbook, } from '@/lib/brandbook'; import { pipelinePublishDay, type PipelineCalendarRow, } from '@/lib/pipeline-calendar'; import type { PublishedNetworkPost } from '@/lib/calendar-strategy'; import { normalizeApprovalStatus } from '@/lib/approval-status'; import { normalizeGenOptions } from '@/lib/calendar-gen-options'; import { collectLinkedInUsage, loadLinkedInStrategyContext, pickUnpublishedStrategyPosts, resolveLinkedInStrategyUrl, } from '@/lib/linkedin-strategy'; export const dynamic = 'force-dynamic'; export const maxDuration = 60; 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 body = (await req.json().catch(() => ({}))) as { month?: string; avoid_titles?: string[]; gen_options?: Record; options?: Record; }; const month = String(body.month || '').slice(0, 7); if (!/^\d{4}-\d{2}$/.test(month)) { return NextResponse.json({ error: 'month YYYY-MM requerido' }, { status: 400 }); } const avoidTitles = Array.isArray(body.avoid_titles) ? body.avoid_titles .filter((t): t is string => typeof t === 'string' && t.trim().length > 0) .map((t) => t.trim().slice(0, 160)) .slice(0, 40) : []; const genOptionsRaw = (body.gen_options || body.options || null) as | Record | null; const genOptions = normalizeGenOptions(genOptionsRaw || undefined); let slug = 'cliente'; const t = await restGet<{ slug?: string }[]>( `/rest/v1/tenants?id=eq.${encodeURIComponent(ctx.tenantId)}&select=slug` ); slug = t.data?.[0]?.slug || ctx.label.toLowerCase().replace(/\s+/g, '-'); const docs = await restGet<{ content?: unknown }[]>( `/rest/v1/documents?tenant_id=eq.${encodeURIComponent( ctx.tenantId )}&type=eq.calendar&select=content&order=created_at.desc&limit=48` ); const history: RrssMonthPlan[] = []; for (const d of docs.data || []) { const plan = asPlanObject(d.content) as RrssMonthPlan | null; if (plan?.month && plan.month !== month && Array.isArray(plan.pieces)) { history.push(plan as unknown as RrssMonthPlan); } } // Prefer same calendar month from prior years first (e.g. 2025-11 before 2026-10) const mm = month.slice(5, 7); history.sort((a, b) => { const aSame = a.month.slice(5, 7) === mm ? 0 : 1; const bSame = b.month.slice(5, 7) === mm ? 0 : 1; if (aSame !== bSame) return aSame - bSame; return String(b.month).localeCompare(String(a.month)); }); const bb = await restGet<{ content?: unknown }[]>( `/rest/v1/documents?tenant_id=eq.${encodeURIComponent( ctx.tenantId )}&type=eq.${BRANDBOOK_DOC_TYPE}&select=content&order=updated_at.desc&limit=1` ); const brandbook = parseBrandbook(bb.data?.[0]?.content); // Publicaciones reales en redes (pipeline): publicadas o con rastro IG const pipe = await restGet( `/rest/v1/creative_pipeline?tenant_id=eq.${encodeURIComponent( ctx.tenantId )}&select=id,title,format,month,status,caption,notes,created_at,scheduled_at,channels,publish_status,external_ref,published_at&order=created_at.desc&limit=400` ); const publishedPosts: PublishedNetworkPost[] = []; for (const row of pipe.data || []) { const st = normalizeApprovalStatus(row.status); const ref = String(row.external_ref || '').toLowerCase(); // Solo piezas ya salidas / sync IG (no borradores del pipeline) if ( st !== 'publicada' && row.publish_status !== 'published' && !ref.startsWith('ig-') ) { continue; } const day = pipelinePublishDay(row) || row.published_at?.slice(0, 10) || row.created_at?.slice(0, 10) || null; publishedPosts.push({ title: row.title, caption: row.caption, format: row.format, day, month: row.month || (day ? day.slice(0, 7) : null), status: row.status, channels: row.channels, }); } let linkedinStrategyUrl = ''; let linkedinPrompt: string | undefined; let linkedinStrategyPosts: | import('@/lib/linkedin-strategy').LinkedInStrategyPost[] | undefined; if (genOptions.linkedinPosts > 0 || genOptions.linkedinStrategyUrl) { linkedinStrategyUrl = resolveLinkedInStrategyUrl({ brandbook, slug, overrideUrl: genOptions.linkedinStrategyUrl, }); if (linkedinStrategyUrl) { const liCtx = await loadLinkedInStrategyContext({ url: linkedinStrategyUrl, brandbook, mode: genOptions.linkedinMode, }); if (liCtx) { linkedinStrategyPosts = liCtx.posts; const usage = collectLinkedInUsage({ history, publishedPosts, avoidTitles, }); const pick = pickUnpublishedStrategyPosts( liCtx.posts, usage, genOptions.linkedinPosts || liCtx.posts.length ); linkedinPrompt = [ liCtx.promptBlock, `ESTADO BACKLOG ESTRATEGIA: ${pick.publishedOrPlanned.length} ya publicados o planificados · ${pick.unpublished.length} pendientes.`, pick.publishedOrPlanned.length ? `YA USADOS (no re-agendar): ${pick.publishedOrPlanned .slice(0, 40) .map((p) => p.id) .join(', ')}` : '', pick.unpublished.length ? `PENDIENTES (priorizar en orden): ${pick.unpublished .slice(0, 24) .map((p) => `${p.id} — ${p.title}`) .join(' | ')}` : 'Backlog agotado → crear ideas nuevas en línea de marca.', ] .filter(Boolean) .join('\n'); } } } const result = await generateMonthPreview({ ym: month, slug, label: ctx.label, history, brandbook, publishedPosts, avoidTitles, genOptions, linkedinStrategyUrl: linkedinStrategyUrl || undefined, linkedinPrompt, linkedinStrategyPosts, }); return NextResponse.json({ ok: true, plan: result.plan, rationale: result.rationale, source: result.source, brand: slug, brandbook: !!brandbook, linkedin_strategy_url: linkedinStrategyUrl || null, linkedin_posts_from_strategy: linkedinStrategyPosts?.length || 0, linkedin_schedule: result.linkedinMeta || null, history_months: history.length, network_posts: publishedPosts.length, }); }