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>
220 lines
7.3 KiB
TypeScript
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,
|
|
});
|
|
}
|