Files
4ventures-client-portal/src/app/api/calendar/preview/route.ts
4Ventures 4a1abd3318 feat: LinkedIn editorial, accesos agencia estables y deshacer en historial
Integra estrategias LinkedIn en calendarios (backlog publicado vs pendiente), unifica roles/cookies de vista agencia y permite deshacer cambios de brandbook dejando constancia en el historial.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-16 04:15:51 +02:00

220 lines
7.3 KiB
TypeScript

/**
* 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<string, unknown>;
options?: Record<string, unknown>;
};
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<string, unknown>
| 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<PipelineCalendarRow[]>(
`/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,
});
}