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>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { isAgencyRole, userRole as resolveUserRole } from '@/lib/agency';
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
@@ -10,8 +11,7 @@ export async function POST(req: NextRequest) {
|
||||
return NextResponse.json({ error: 'No autorizado. Inicia sesión.' }, { status: 401 });
|
||||
}
|
||||
|
||||
const userRole = user.app_metadata?.role || user.user_metadata?.role || 'client';
|
||||
if (userRole !== 'admin' && userRole !== 'team') {
|
||||
if (!isAgencyRole(resolveUserRole(user))) {
|
||||
return NextResponse.json({ error: 'Permisos insuficientes. Solo administradores pueden crear clientes.' }, { status: 403 });
|
||||
}
|
||||
|
||||
|
||||
@@ -190,7 +190,7 @@ export async function GET(req: NextRequest) {
|
||||
mapped = filterDriveEntriesForClientRoot(mapped);
|
||||
if (!mapped.length && !scopeWarning) {
|
||||
scopeWarning =
|
||||
'Aún no hay carpetas generadas por la app (p. ej. Pipeline-creatividades). El resto del Drive solo lo ve la agencia.';
|
||||
'Aún no hay carpetas generadas por la app (Creatividades-Ads / Creatividades-RRSS). El resto del Drive solo lo ve la agencia.';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,11 @@ import {
|
||||
seedBrandbook,
|
||||
type Brandbook,
|
||||
} from '@/lib/brandbook';
|
||||
import { diffFields, logChange } from '@/lib/change-history';
|
||||
import {
|
||||
buildRevertPatch,
|
||||
diffFields,
|
||||
logChange,
|
||||
} from '@/lib/change-history';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
@@ -286,6 +290,14 @@ export async function PUT(req: NextRequest) {
|
||||
book as unknown as Record<string, unknown>
|
||||
);
|
||||
|
||||
const revertPatch =
|
||||
!body.seed && !created
|
||||
? buildRevertPatch(
|
||||
previousBook as unknown as Record<string, unknown>,
|
||||
book as unknown as Record<string, unknown>
|
||||
)
|
||||
: {};
|
||||
|
||||
await logChange({
|
||||
tenantId,
|
||||
tenantName: label,
|
||||
@@ -306,6 +318,8 @@ export async function PUT(req: NextRequest) {
|
||||
: `Guardado Zona de marca · ${label}`,
|
||||
detail: {
|
||||
changes: changes.slice(0, 40),
|
||||
revert_patch: revertPatch,
|
||||
revertable: Object.keys(revertPatch).length > 0,
|
||||
seed: Boolean(body.seed),
|
||||
slug,
|
||||
},
|
||||
|
||||
116
src/app/api/calendar/linkedin-strategy/route.ts
Normal file
116
src/app/api/calendar/linkedin-strategy/route.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* GET — resolve LinkedIn strategy URL + summary for active tenant
|
||||
* (brandbook URL, known slug map, optional ?url= override).
|
||||
* Used by CalendarCreator / zoom to show associated strategy content.
|
||||
*/
|
||||
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 {
|
||||
BRANDBOOK_DOC_TYPE,
|
||||
parseBrandbook,
|
||||
} from '@/lib/brandbook';
|
||||
import {
|
||||
loadLinkedInStrategyContext,
|
||||
resolveLinkedInStrategyUrl,
|
||||
} from '@/lib/linkedin-strategy';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const maxDuration = 45;
|
||||
|
||||
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 });
|
||||
}
|
||||
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 override = req.nextUrl.searchParams.get('url') || '';
|
||||
const postId = req.nextUrl.searchParams.get('post_id') || '';
|
||||
|
||||
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 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);
|
||||
|
||||
const url = resolveLinkedInStrategyUrl({
|
||||
brandbook,
|
||||
slug,
|
||||
overrideUrl: override,
|
||||
});
|
||||
|
||||
if (!url) {
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
url: null,
|
||||
brandbook_url: brandbook?.linkedin_strategy_url || null,
|
||||
posts: [],
|
||||
themes: brandbook?.linkedin_themes || [],
|
||||
formats: brandbook?.linkedin_formats || [],
|
||||
ctas: brandbook?.linkedin_cta || [],
|
||||
message: 'Sin URL de estrategia LinkedIn en brandbook ni mapa conocido.',
|
||||
});
|
||||
}
|
||||
|
||||
const ctxLi = await loadLinkedInStrategyContext({
|
||||
url,
|
||||
brandbook,
|
||||
mode: 'from_strategy',
|
||||
});
|
||||
|
||||
const posts = (ctxLi?.posts || []).map((p) => ({
|
||||
id: p.id,
|
||||
n: p.n,
|
||||
title: p.title,
|
||||
meta: p.meta,
|
||||
angle: p.angle,
|
||||
body_preview: (p.body || '').slice(0, 500),
|
||||
body: postId && p.id === postId ? p.body : undefined,
|
||||
}));
|
||||
|
||||
let selected = postId
|
||||
? ctxLi?.posts.find((p) => p.id === postId) || null
|
||||
: null;
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
url,
|
||||
brandbook_url: brandbook?.linkedin_strategy_url || null,
|
||||
posts_count: ctxLi?.posts.length || 0,
|
||||
posts: posts.slice(0, 80),
|
||||
selected: selected
|
||||
? {
|
||||
id: selected.id,
|
||||
title: selected.title,
|
||||
meta: selected.meta,
|
||||
angle: selected.angle,
|
||||
body: selected.body,
|
||||
}
|
||||
: null,
|
||||
themes: brandbook?.linkedin_themes || [],
|
||||
formats: brandbook?.linkedin_formats || [],
|
||||
ctas: brandbook?.linkedin_cta || [],
|
||||
thought_leadership: brandbook?.thought_leadership || [],
|
||||
});
|
||||
}
|
||||
@@ -18,6 +18,13 @@ import {
|
||||
} 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;
|
||||
@@ -57,9 +64,10 @@ export async function POST(req: NextRequest) {
|
||||
.map((t) => t.trim().slice(0, 160))
|
||||
.slice(0, 40)
|
||||
: [];
|
||||
const genOptions = (body.gen_options || body.options || null) as
|
||||
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 }[]>(
|
||||
@@ -129,6 +137,58 @@ export async function POST(req: NextRequest) {
|
||||
});
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -137,7 +197,10 @@ export async function POST(req: NextRequest) {
|
||||
brandbook,
|
||||
publishedPosts,
|
||||
avoidTitles,
|
||||
genOptions: genOptions || undefined,
|
||||
genOptions,
|
||||
linkedinStrategyUrl: linkedinStrategyUrl || undefined,
|
||||
linkedinPrompt,
|
||||
linkedinStrategyPosts,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
@@ -147,6 +210,9 @@ export async function POST(req: NextRequest) {
|
||||
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,
|
||||
});
|
||||
|
||||
262
src/app/api/change-history/undo/route.ts
Normal file
262
src/app/api/change-history/undo/route.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
/**
|
||||
* POST /api/change-history/undo
|
||||
* Body: { id: string }
|
||||
* Deshace un cambio reversible y registra el deshacer en el historial.
|
||||
*/
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import {
|
||||
homeTenantId,
|
||||
isAgencyRole,
|
||||
supabaseUrl,
|
||||
userRole,
|
||||
} from '@/lib/agency';
|
||||
import {
|
||||
isHistoryRowRevertable,
|
||||
logChange,
|
||||
type ChangeHistoryRow,
|
||||
} from '@/lib/change-history';
|
||||
import {
|
||||
BRANDBOOK_DOC_TITLE,
|
||||
BRANDBOOK_DOC_TYPE,
|
||||
parseBrandbook,
|
||||
type Brandbook,
|
||||
} from '@/lib/brandbook';
|
||||
import { createClient as createAdminClient } from '@supabase/supabase-js';
|
||||
import { resolveActiveTenant } from '@/lib/tenant-context';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
function getAdmin() {
|
||||
const url = supabaseUrl();
|
||||
const key = process.env.SUPABASE_SERVICE_ROLE_KEY?.trim();
|
||||
if (!url || !key) return null;
|
||||
return createAdminClient(url, key, {
|
||||
auth: { persistSession: false, autoRefreshToken: false },
|
||||
});
|
||||
}
|
||||
|
||||
function patchFromChanges(
|
||||
changes: { field?: string; from?: unknown }[]
|
||||
): Record<string, unknown> {
|
||||
const patch: Record<string, unknown> = {};
|
||||
for (const c of changes) {
|
||||
if (!c?.field) continue;
|
||||
const from = c.from;
|
||||
if (typeof from === 'string' && from.endsWith('…')) {
|
||||
// Valor clipado — no fiable para deshacer
|
||||
continue;
|
||||
}
|
||||
patch[c.field] = from ?? null;
|
||||
}
|
||||
return patch;
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
const role = userRole(user);
|
||||
const isAgency = isAgencyRole(role);
|
||||
const body = (await req.json().catch(() => ({}))) as { id?: string };
|
||||
const id = String(body.id || '').trim();
|
||||
if (!id) {
|
||||
return NextResponse.json({ error: 'id requerido' }, { status: 400 });
|
||||
}
|
||||
|
||||
const admin = getAdmin();
|
||||
if (!admin) {
|
||||
return NextResponse.json({ error: 'Sin service role' }, { status: 503 });
|
||||
}
|
||||
|
||||
const { data: row, error: fetchErr } = await admin
|
||||
.from('change_history')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.maybeSingle();
|
||||
|
||||
if (fetchErr || !row) {
|
||||
return NextResponse.json(
|
||||
{ error: fetchErr?.message || 'Cambio no encontrado' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
const historyRow = row as ChangeHistoryRow;
|
||||
|
||||
// Autorización por tenant
|
||||
if (!isAgency) {
|
||||
const own = homeTenantId(user);
|
||||
const ctx = await resolveActiveTenant(user);
|
||||
const allowed = own || ctx.tenantId;
|
||||
if (!allowed || historyRow.tenant_id !== allowed) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
}
|
||||
|
||||
if (!isHistoryRowRevertable(historyRow)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
'Este cambio no se puede deshacer (ya deshecho, seed/creación, o sin parche reversible).',
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const detail = historyRow.detail || {};
|
||||
let revertPatch: Record<string, unknown> = {};
|
||||
if (
|
||||
detail.revert_patch &&
|
||||
typeof detail.revert_patch === 'object' &&
|
||||
!Array.isArray(detail.revert_patch)
|
||||
) {
|
||||
revertPatch = { ...(detail.revert_patch as Record<string, unknown>) };
|
||||
} else if (Array.isArray(detail.changes)) {
|
||||
revertPatch = patchFromChanges(
|
||||
detail.changes as { field?: string; from?: unknown }[]
|
||||
);
|
||||
}
|
||||
|
||||
if (!Object.keys(revertPatch).length) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
'No hay datos suficientes para deshacer (valores anteriores incompletos).',
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (historyRow.entity_type !== 'brandbook') {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
'De momento solo se pueden deshacer cambios de Zona de marca (brandbook).',
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const tenantId = historyRow.tenant_id;
|
||||
const docId = historyRow.entity_id;
|
||||
if (!tenantId || !docId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Falta tenant o documento en el registro' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { data: doc, error: docErr } = await admin
|
||||
.from('documents')
|
||||
.select('id, content, type')
|
||||
.eq('id', docId)
|
||||
.eq('tenant_id', tenantId)
|
||||
.maybeSingle();
|
||||
|
||||
if (docErr || !doc) {
|
||||
return NextResponse.json(
|
||||
{ error: docErr?.message || 'Documento brandbook no encontrado' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
const current = parseBrandbook(doc.content);
|
||||
if (!current) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Brandbook ilegible' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
const beforeUndo = { ...(current as unknown as Record<string, unknown>) };
|
||||
const restoredFields: string[] = [];
|
||||
const book = { ...current } as Brandbook & Record<string, unknown>;
|
||||
|
||||
for (const [field, value] of Object.entries(revertPatch)) {
|
||||
if (field.startsWith('_')) continue;
|
||||
if (value === null) {
|
||||
// Campo que no existía: vaciar tipos comunes
|
||||
const cur = book[field];
|
||||
if (Array.isArray(cur)) book[field] = [];
|
||||
else if (typeof cur === 'string') book[field] = '';
|
||||
else if (typeof cur === 'object' && cur) book[field] = value;
|
||||
else book[field] = value;
|
||||
} else {
|
||||
book[field] = value as never;
|
||||
}
|
||||
restoredFields.push(field);
|
||||
}
|
||||
|
||||
book.updated_at = new Date().toISOString();
|
||||
|
||||
const { error: patchErr } = await admin
|
||||
.from('documents')
|
||||
.update({
|
||||
content: book,
|
||||
title: BRANDBOOK_DOC_TITLE,
|
||||
updated_at: book.updated_at,
|
||||
type: BRANDBOOK_DOC_TYPE,
|
||||
})
|
||||
.eq('id', docId);
|
||||
|
||||
if (patchErr) {
|
||||
return NextResponse.json({ error: patchErr.message }, { status: 500 });
|
||||
}
|
||||
|
||||
// Marca el original como deshecho (sigue visible en historial)
|
||||
const markedDetail = {
|
||||
...detail,
|
||||
undone: true,
|
||||
undone_at: new Date().toISOString(),
|
||||
undone_by: user.email || user.id,
|
||||
};
|
||||
await admin
|
||||
.from('change_history')
|
||||
.update({ detail: markedDetail })
|
||||
.eq('id', id);
|
||||
|
||||
const label =
|
||||
historyRow.tenant_name ||
|
||||
(await resolveActiveTenant(user)).label ||
|
||||
'cliente';
|
||||
|
||||
await logChange({
|
||||
tenantId,
|
||||
tenantName: label,
|
||||
actor: {
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
role,
|
||||
},
|
||||
entityType: 'brandbook',
|
||||
entityId: docId,
|
||||
action: 'undo',
|
||||
summary: `Deshacer · ${historyRow.summary}`.slice(0, 500),
|
||||
detail: {
|
||||
undoes: id,
|
||||
undoes_summary: historyRow.summary,
|
||||
undoes_at: historyRow.created_at,
|
||||
restored_fields: restoredFields,
|
||||
changes: restoredFields.map((f) => ({
|
||||
field: f,
|
||||
from: beforeUndo[f] ?? null,
|
||||
to: revertPatch[f] ?? null,
|
||||
})),
|
||||
revertable: false,
|
||||
note: 'El cambio original permanece en el historial marcado como deshecho.',
|
||||
},
|
||||
source: 'api/change-history/undo',
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
undid: id,
|
||||
restored_fields: restoredFields,
|
||||
});
|
||||
}
|
||||
@@ -74,10 +74,16 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
const { data: tenant } = await db
|
||||
.from('tenants')
|
||||
.select('id, slug, name')
|
||||
.select('id, slug, name, canva_templates')
|
||||
.eq('id', tenantId)
|
||||
.maybeSingle();
|
||||
|
||||
const { resolveTenantCanvaTemplates } = await import('@/lib/canva-templates');
|
||||
const canvaTemplates = resolveTenantCanvaTemplates(tenant?.canva_templates, {
|
||||
slug: tenant?.slug,
|
||||
channel: 'rrss',
|
||||
});
|
||||
|
||||
// Recent pipeline for continuity
|
||||
const { data: recentRows } = await db
|
||||
.from('creative_pipeline')
|
||||
@@ -216,6 +222,7 @@ export async function POST(req: NextRequest) {
|
||||
brandbookPrompt,
|
||||
socialHistory,
|
||||
variantsRequested: Number(body.variants_requested || 2),
|
||||
canvaTemplates,
|
||||
});
|
||||
|
||||
const { data: gr, error: grErr } = await db
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from '@/lib/agency';
|
||||
import { resolveActiveTenant } from '@/lib/tenant-context';
|
||||
import {
|
||||
channelsForCalendarPlatform,
|
||||
normalizeChannels,
|
||||
parseScheduledAt,
|
||||
PUBLISH_STATUSES,
|
||||
@@ -227,13 +228,7 @@ export async function POST(req: NextRequest) {
|
||||
scheduledAt = scheduledAtFromPiece(cal);
|
||||
}
|
||||
if (!channels) {
|
||||
const plat = (cal.platform || '').toLowerCase();
|
||||
channels =
|
||||
plat.includes('linkedin')
|
||||
? ['instagram']
|
||||
: plat.includes('facebook')
|
||||
? ['facebook', 'instagram']
|
||||
: ['instagram', 'facebook'];
|
||||
channels = channelsForCalendarPlatform(cal.platform);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ const ALLOWED_MIME = new Set(['image/jpeg', 'image/png', 'image/webp']);
|
||||
* fields: file, itemId (requerido), slot ('cover' | 'slide'), index? (para slide, 0-based)
|
||||
*
|
||||
* También espeja el archivo en Drive del cliente:
|
||||
* {carpeta-cliente}/Pipeline-creatividades_{slug}/YYYY/MM/DD-MM-YYYY_formato_hook[-N].ext
|
||||
* {carpeta-cliente}/Creatividades-RRSS_{slug}/YYYY/MM/{slug}_rrss_{formato}_{fecha}_vN.ext
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
const supabase = await createClient();
|
||||
|
||||
@@ -15,9 +15,11 @@ import {
|
||||
serviceSupabase,
|
||||
} from '@/lib/assets';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { syncCreativeAssetToDrive } from '@/lib/creatives-drive';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const runtime = 'nodejs';
|
||||
export const maxDuration = 90;
|
||||
|
||||
const ALLOWED_MIME = new Set([
|
||||
'image/jpeg',
|
||||
@@ -170,6 +172,8 @@ export async function POST(req: NextRequest) {
|
||||
patch.asset_final = true;
|
||||
}
|
||||
|
||||
let creative: Record<string, unknown> | null = null;
|
||||
|
||||
if (agency && headers) {
|
||||
const r = await fetch(
|
||||
`${supabaseUrl()}/rest/v1/creatives?id=eq.${encodeURIComponent(creativeId)}`,
|
||||
@@ -188,21 +192,53 @@ export async function POST(req: NextRequest) {
|
||||
return NextResponse.json({ error: await r.text() }, { status: 500 });
|
||||
}
|
||||
const data = await r.json();
|
||||
return NextResponse.json({
|
||||
creative: Array.isArray(data) ? data[0] : data,
|
||||
media_url: mediaUrl,
|
||||
});
|
||||
creative = Array.isArray(data) ? data[0] : data;
|
||||
} else {
|
||||
const { data, error } = await supabase
|
||||
.from('creatives')
|
||||
.update(patch)
|
||||
.eq('id', creativeId)
|
||||
.select('*')
|
||||
.single();
|
||||
if (error) {
|
||||
await svc.storage.from(ASSETS_BUCKET).remove([storagePath]);
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
creative = data;
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('creatives')
|
||||
.update(patch)
|
||||
.eq('id', creativeId)
|
||||
.select('*')
|
||||
.single();
|
||||
if (error) {
|
||||
await svc.storage.from(ASSETS_BUCKET).remove([storagePath]);
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
return NextResponse.json({ creative: data, media_url: mediaUrl });
|
||||
const ext =
|
||||
safeName.split('.').pop()?.toLowerCase() ||
|
||||
(mime.includes('png') ? 'png' : mime.includes('webp') ? 'webp' : 'jpg');
|
||||
|
||||
const drive = await syncCreativeAssetToDrive({
|
||||
tenantId: ctx.tenantId,
|
||||
clientLabel: ctx.label || 'cliente',
|
||||
channel: 'ads',
|
||||
item: {
|
||||
id: creativeId,
|
||||
title: String(creative?.title || title),
|
||||
format: String(creative?.format || (mime.startsWith('video/') ? 'video' : 'image')),
|
||||
created_at: String(creative?.created_at || new Date().toISOString()),
|
||||
angle: creative?.angle ? String(creative.angle) : null,
|
||||
hook: creative?.hook ? String(creative.hook) : null,
|
||||
},
|
||||
buffer: buf,
|
||||
mimeType: mime,
|
||||
ext,
|
||||
slot: 'asset',
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
creative,
|
||||
media_url: mediaUrl,
|
||||
drive: {
|
||||
ok: drive.ok,
|
||||
skipped: drive.skipped || false,
|
||||
path: drive.drivePath || null,
|
||||
fileName: drive.fileName || null,
|
||||
fileId: drive.driveFileId || null,
|
||||
warning: drive.warning || null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
parseGenerationRequestIdFromNotes,
|
||||
stripGenerationRequestMarker,
|
||||
} from '@/lib/rrss-generation';
|
||||
import { backfillCreativeItemToDrive } from '@/lib/creatives-drive';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
@@ -110,7 +111,7 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
const { data: item } = await db
|
||||
.from('creative_pipeline')
|
||||
.select('id, notes, caption, format')
|
||||
.select('id, notes, caption, format, title, scheduled_at, created_at')
|
||||
.eq('id', pipelineId)
|
||||
.eq('tenant_id', tenantId)
|
||||
.maybeSingle();
|
||||
@@ -236,5 +237,42 @@ export async function POST(req: NextRequest) {
|
||||
.eq('tenant_id', tenantId);
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true, item: updated });
|
||||
// Mirror final assets into Creatividades-RRSS_{slug}/YYYY/MM/…
|
||||
let drive: { ok: boolean; detail?: string } | null = null;
|
||||
if (coverPath && !/^https?:\/\//i.test(coverPath)) {
|
||||
try {
|
||||
const { data: tenant } = await db
|
||||
.from('tenants')
|
||||
.select('slug,name')
|
||||
.eq('id', tenantId)
|
||||
.maybeSingle();
|
||||
const bf = await backfillCreativeItemToDrive({
|
||||
tenantId,
|
||||
clientLabel: tenant?.slug || tenant?.name || 'cliente',
|
||||
channel: 'rrss',
|
||||
item: {
|
||||
id: pipelineId,
|
||||
title: updated.title || item.title,
|
||||
format: updated.format || item.format,
|
||||
caption: updated.caption,
|
||||
notes: updated.notes,
|
||||
scheduled_at: updated.scheduled_at || item.scheduled_at,
|
||||
created_at: updated.created_at || item.created_at,
|
||||
cover_path: updated.cover_path,
|
||||
slide_paths: updated.slide_paths,
|
||||
},
|
||||
});
|
||||
drive = {
|
||||
ok: bf.ok,
|
||||
detail: bf.results.map((x) => `${x.slot}:${x.detail}`).join(' | '),
|
||||
};
|
||||
} catch (e) {
|
||||
drive = {
|
||||
ok: false,
|
||||
detail: e instanceof Error ? e.message : 'drive sync failed',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true, item: updated, drive });
|
||||
}
|
||||
|
||||
@@ -38,18 +38,11 @@ export async function GET(
|
||||
.eq('id', tenantId)
|
||||
.maybeSingle();
|
||||
|
||||
const defaultBpsTemplate = process.env.BPS_CANVA_TEMPLATE_ID || 'EAHRb38v_WY';
|
||||
let canvaTemplates = (client as any)?.canva_templates || {};
|
||||
if (
|
||||
client?.slug === 'bps' &&
|
||||
(!canvaTemplates || Object.keys(canvaTemplates).length === 0)
|
||||
) {
|
||||
canvaTemplates = {
|
||||
'foto-con-franja': defaultBpsTemplate,
|
||||
tipografico: null,
|
||||
default: defaultBpsTemplate,
|
||||
};
|
||||
}
|
||||
const { resolveTenantCanvaTemplates } = await import('@/lib/canva-templates');
|
||||
const canvaTemplates = resolveTenantCanvaTemplates(
|
||||
(client as { canva_templates?: unknown })?.canva_templates,
|
||||
{ slug: client?.slug, channel: 'ads' }
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
@@ -65,7 +58,7 @@ export async function GET(
|
||||
meta_publish_status: client?.meta_publish_status || 'PAUSED',
|
||||
slack_channel_id: client?.slack_channel_id,
|
||||
canva_templates: canvaTemplates,
|
||||
drive_folder_id: (client as any)?.drive_folder_id || null,
|
||||
drive_folder_id: (client as { drive_folder_id?: string | null })?.drive_folder_id || null,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -186,9 +179,83 @@ export async function PATCH(
|
||||
});
|
||||
}
|
||||
|
||||
// Mirror final Ads asset → Creatividades-Ads_{slug}/YYYY/MM/{slug}_ads_…
|
||||
let drive: { ok: boolean; path?: string; warning?: string } | null = null;
|
||||
if (updates.asset_final || updates.asset_url || updates.asset_final === true) {
|
||||
try {
|
||||
const { syncCreativeAssetToDrive } = await import('@/lib/creatives-drive');
|
||||
const { ASSETS_BUCKET, serviceSupabase } = await import('@/lib/assets');
|
||||
const { data: tenant } = await adminClient
|
||||
.from('tenants')
|
||||
.select('slug,name')
|
||||
.eq('id', tenantId)
|
||||
.maybeSingle();
|
||||
|
||||
let buffer: Buffer | null = null;
|
||||
let mimeType = 'image/jpeg';
|
||||
let ext = 'jpg';
|
||||
const storagePath = updated.storage_path || creative.storage_path;
|
||||
const assetUrl = updated.asset_url || creative.asset_url;
|
||||
|
||||
if (storagePath && typeof storagePath === 'string' && !/^https?:\/\//i.test(storagePath)) {
|
||||
const svc = serviceSupabase();
|
||||
if (svc) {
|
||||
const dl = await svc.storage.from(ASSETS_BUCKET).download(storagePath);
|
||||
if (dl.data) {
|
||||
buffer = Buffer.from(await dl.data.arrayBuffer());
|
||||
mimeType = dl.data.type || mimeType;
|
||||
ext = storagePath.split('.').pop()?.toLowerCase() || ext;
|
||||
}
|
||||
}
|
||||
} else if (assetUrl && typeof assetUrl === 'string' && /^https?:\/\//i.test(assetUrl)) {
|
||||
const r = await fetch(assetUrl, { cache: 'no-store' });
|
||||
if (r.ok) {
|
||||
buffer = Buffer.from(await r.arrayBuffer());
|
||||
mimeType = r.headers.get('content-type') || mimeType;
|
||||
ext = mimeType.includes('png')
|
||||
? 'png'
|
||||
: mimeType.includes('webp')
|
||||
? 'webp'
|
||||
: 'jpg';
|
||||
}
|
||||
}
|
||||
|
||||
if (buffer) {
|
||||
const sync = await syncCreativeAssetToDrive({
|
||||
tenantId,
|
||||
clientLabel: tenant?.slug || tenant?.name || 'cliente',
|
||||
channel: 'ads',
|
||||
item: {
|
||||
id,
|
||||
title: updated.title || creative.title,
|
||||
format: updated.format || creative.format || 'image',
|
||||
created_at: updated.created_at || creative.created_at,
|
||||
angle: updated.angle || creative.angle,
|
||||
hook: updated.hook || creative.hook,
|
||||
},
|
||||
buffer,
|
||||
mimeType,
|
||||
ext,
|
||||
slot: 'asset',
|
||||
});
|
||||
drive = {
|
||||
ok: sync.ok,
|
||||
path: sync.drivePath,
|
||||
warning: sync.warning,
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
drive = {
|
||||
ok: false,
|
||||
warning: e instanceof Error ? e.message : 'drive sync failed',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
data: updated,
|
||||
drive,
|
||||
...updated,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -114,18 +114,11 @@ export async function POST(req: NextRequest) {
|
||||
.eq('id', tenantId)
|
||||
.maybeSingle();
|
||||
|
||||
const defaultBpsTemplate = process.env.BPS_CANVA_TEMPLATE_ID || 'EAHRb38v_WY';
|
||||
let canvaTemplates = tenant?.canva_templates || {};
|
||||
if (
|
||||
tenant?.slug === 'bps' &&
|
||||
(!canvaTemplates || Object.keys(canvaTemplates).length === 0)
|
||||
) {
|
||||
canvaTemplates = {
|
||||
'foto-con-franja': defaultBpsTemplate,
|
||||
tipografico: null,
|
||||
default: defaultBpsTemplate,
|
||||
};
|
||||
}
|
||||
const { resolveTenantCanvaTemplates } = await import('@/lib/canva-templates');
|
||||
const canvaTemplates = resolveTenantCanvaTemplates(tenant?.canva_templates, {
|
||||
slug: tenant?.slug,
|
||||
channel: 'ads',
|
||||
});
|
||||
|
||||
const clientInfo = {
|
||||
id: tenant?.id || tenantId,
|
||||
@@ -227,7 +220,7 @@ export async function GET(req: NextRequest) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
|
||||
const defaultBpsTemplate = process.env.BPS_CANVA_TEMPLATE_ID || 'EAHRb38v_WY';
|
||||
const { resolveTenantCanvaTemplates } = await import('@/lib/canva-templates');
|
||||
|
||||
const enriched = (rows || [])
|
||||
.filter((row: any) => {
|
||||
@@ -236,17 +229,10 @@ export async function GET(req: NextRequest) {
|
||||
})
|
||||
.map((row: any) => {
|
||||
const { tenant, ...rest } = row;
|
||||
let canvaTemplates = tenant?.canva_templates || {};
|
||||
if (
|
||||
tenant?.slug === 'bps' &&
|
||||
(!canvaTemplates || Object.keys(canvaTemplates).length === 0)
|
||||
) {
|
||||
canvaTemplates = {
|
||||
'foto-con-franja': defaultBpsTemplate,
|
||||
tipografico: null,
|
||||
default: defaultBpsTemplate,
|
||||
};
|
||||
}
|
||||
const canvaTemplates = resolveTenantCanvaTemplates(tenant?.canva_templates, {
|
||||
slug: tenant?.slug,
|
||||
channel: 'ads',
|
||||
});
|
||||
|
||||
const item: Record<string, any> = {
|
||||
...rest,
|
||||
|
||||
@@ -1,10 +1,27 @@
|
||||
'use server';
|
||||
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { redirect } from 'next/navigation';
|
||||
import {
|
||||
VIEW_TENANT_COOKIE,
|
||||
VIEW_TENANT_NAME_COOKIE,
|
||||
VIEW_TENANT_SLUG_COOKIE,
|
||||
clearedViewTenantCookieOpts,
|
||||
} from '@/lib/agency';
|
||||
|
||||
export async function logoutAction() {
|
||||
const supabase = await createClient();
|
||||
await supabase.auth.signOut();
|
||||
|
||||
// Evita que el siguiente login herede "vista cliente" de otra sesión.
|
||||
const jar = await cookies();
|
||||
const opts = clearedViewTenantCookieOpts(
|
||||
process.env.NODE_ENV === 'production'
|
||||
);
|
||||
jar.set(VIEW_TENANT_COOKIE, '', opts);
|
||||
jar.set(VIEW_TENANT_SLUG_COOKIE, '', opts);
|
||||
jar.set(VIEW_TENANT_NAME_COOKIE, '', opts);
|
||||
|
||||
redirect('/login');
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import AgencyPortfolioBoard from '@/components/AgencyPortfolioBoard';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { PageHeader } from '@/components/ui';
|
||||
import { isAgencyRole, userRole } from '@/lib/agency';
|
||||
|
||||
export const revalidate = 0;
|
||||
|
||||
@@ -15,8 +16,7 @@ export default async function AgencyPage() {
|
||||
} = await supabase.auth.getUser();
|
||||
if (!user) redirect('/login');
|
||||
|
||||
const role = user.app_metadata?.role || user.user_metadata?.role || 'client';
|
||||
if (role !== 'admin' && role !== 'team') {
|
||||
if (!isAgencyRole(userRole(user))) {
|
||||
redirect('/dashboard/kpis');
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from 'react';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { logoutAction } from '@/app/auth/actions';
|
||||
import { cookies } from 'next/headers';
|
||||
import { redirect } from 'next/navigation';
|
||||
import {
|
||||
VIEW_TENANT_COOKIE,
|
||||
VIEW_TENANT_NAME_COOKIE,
|
||||
@@ -30,16 +31,26 @@ export default async function DashboardLayout({
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
|
||||
// Sin usuario real no falsificamos identidad (antes salía usuario@4ventures.es).
|
||||
if (!user?.email) {
|
||||
redirect('/login');
|
||||
}
|
||||
|
||||
const role = userRole(user);
|
||||
const company =
|
||||
(user?.user_metadata?.company as string) ||
|
||||
(user?.app_metadata?.company as string) ||
|
||||
'4Ventures Central';
|
||||
const email = user?.email || 'usuario@4ventures.es';
|
||||
(user.user_metadata?.company as string) ||
|
||||
(user.app_metadata?.company as string) ||
|
||||
(isAgencyRole(role) ? '4Ventures' : 'Portal cliente');
|
||||
const email = user.email;
|
||||
const isAgency = isAgencyRole(role);
|
||||
const cookieStore = await cookies();
|
||||
const viewName = cookieStore.get(VIEW_TENANT_NAME_COOKIE)?.value;
|
||||
const viewId = cookieStore.get(VIEW_TENANT_COOKIE)?.value || null;
|
||||
// Cookies de zoom solo aplican a agencia (evita chrome cliente en ops).
|
||||
const viewName = isAgency
|
||||
? cookieStore.get(VIEW_TENANT_NAME_COOKIE)?.value
|
||||
: undefined;
|
||||
const viewId = isAgency
|
||||
? cookieStore.get(VIEW_TENANT_COOKIE)?.value || null
|
||||
: null;
|
||||
const ownId = homeTenantId(user);
|
||||
|
||||
let enabledModules: Record<DashboardModuleId, boolean> | null = null;
|
||||
@@ -94,9 +105,7 @@ export default async function DashboardLayout({
|
||||
isAgency={isAgency}
|
||||
viewName={viewName}
|
||||
enabledModules={enabledModules}
|
||||
clientLogoUrl={
|
||||
isAgency && viewId ? clientLogoUrl : !isAgency ? clientLogoUrl : null
|
||||
}
|
||||
clientLogoUrl={!isAgency ? clientLogoUrl : null}
|
||||
brandLabel={brandLabel}
|
||||
logoutAction={logoutAction}
|
||||
>
|
||||
|
||||
@@ -68,7 +68,12 @@ function LoginForm() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="space-y-4"
|
||||
autoComplete="on"
|
||||
data-form-type="login"
|
||||
>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1.5">
|
||||
Correo Electrónico
|
||||
@@ -79,10 +84,12 @@ function LoginForm() {
|
||||
</div>
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
autoComplete="username"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="ejemplo@cliente.com"
|
||||
placeholder="marketing@4ventures.es"
|
||||
className="w-full pl-10 pr-4 py-2.5 bg-gray-50 border border-gray-200 rounded-xl text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:border-brand-500 focus:ring-1 focus:ring-brand-500 transition-all"
|
||||
/>
|
||||
</div>
|
||||
@@ -98,6 +105,8 @@ function LoginForm() {
|
||||
</div>
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
name="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
|
||||
@@ -922,7 +922,7 @@ export default function AssetsLibrary({
|
||||
<p className="text-[11px] text-gray-500">
|
||||
{isAgency
|
||||
? `Misma carpeta enlazada en Agencia (Clientes → ${driveFolder || '…'}).`
|
||||
: 'Solo carpetas creadas por la app (p. ej. Pipeline-creatividades). El resto del Drive del cliente no se muestra.'}
|
||||
: 'Solo carpetas creadas por la app (Creatividades-Ads / Creatividades-RRSS, calendarios, brandbook…). El resto del Drive del cliente no se muestra.'}
|
||||
</p>
|
||||
{loading ? (
|
||||
<p className="text-sm text-gray-500">Cargando Drive…</p>
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import {
|
||||
CalendarPlus,
|
||||
ExternalLink,
|
||||
Instagram,
|
||||
Linkedin,
|
||||
Loader2,
|
||||
Plus,
|
||||
Sparkles,
|
||||
@@ -138,20 +141,67 @@ export default function CalendarCreator({
|
||||
...DEFAULT_GEN_OPTIONS,
|
||||
});
|
||||
const [showGenPanel, setShowGenPanel] = useState(true);
|
||||
/** Pestaña del creador: IG/FB vs LinkedIn */
|
||||
const [creatorTab, setCreatorTab] = useState<'ig-fb' | 'linkedin'>('ig-fb');
|
||||
const [liStrategyMeta, setLiStrategyMeta] = useState<{
|
||||
url: string | null;
|
||||
posts_count: number;
|
||||
themes: string[];
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const r = await fetch('/api/calendar/linkedin-strategy');
|
||||
const j = await r.json();
|
||||
if (!r.ok || cancelled) return;
|
||||
setLiStrategyMeta({
|
||||
url: j.url || j.brandbook_url || null,
|
||||
posts_count: j.posts_count || 0,
|
||||
themes: j.themes || [],
|
||||
});
|
||||
if (j.url || j.brandbook_url) {
|
||||
setGenOptions((o) =>
|
||||
o.linkedinStrategyUrl
|
||||
? o
|
||||
: {
|
||||
...o,
|
||||
linkedinStrategyUrl: String(j.url || j.brandbook_url || ''),
|
||||
}
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const monthChoices = useMemo(() => {
|
||||
const base = ymNow();
|
||||
return Array.from({ length: 6 }, (_, i) => addMonths(base, i));
|
||||
}, []);
|
||||
|
||||
const filteredPieces = useMemo(() => {
|
||||
const all = plan?.pieces || [];
|
||||
if (creatorTab === 'linkedin') {
|
||||
return all.filter((p) => /linkedin/i.test(p.platform || ''));
|
||||
}
|
||||
return all.filter((p) => !/linkedin/i.test(p.platform || ''));
|
||||
}, [plan, creatorTab]);
|
||||
|
||||
const byDate = useMemo(() => {
|
||||
const m = new Map<string, RrssPiece[]>();
|
||||
for (const p of plan?.pieces || []) {
|
||||
for (const p of filteredPieces) {
|
||||
const k = p.date.slice(0, 10);
|
||||
(m.get(k) || m.set(k, []).get(k)!).push(p);
|
||||
}
|
||||
return m;
|
||||
}, [plan]);
|
||||
}, [filteredPieces]);
|
||||
|
||||
const grid = useMemo(() => {
|
||||
if (!month) return { pad: 0, days: 0 };
|
||||
@@ -213,7 +263,21 @@ export default function CalendarCreator({
|
||||
setBusy(true);
|
||||
setErr(null);
|
||||
try {
|
||||
const opts = normalizeGenOptions(genOptions);
|
||||
let opts = normalizeGenOptions(genOptions);
|
||||
// Pestaña LinkedIn: prioriza LI (default 8) y no regenera IG/FB en este preview
|
||||
if (creatorTab === 'linkedin') {
|
||||
if (opts.linkedinPosts <= 0) {
|
||||
opts = { ...opts, linkedinPosts: 8 };
|
||||
setGenOptions((o) => ({ ...o, linkedinPosts: 8 }));
|
||||
}
|
||||
opts = {
|
||||
...opts,
|
||||
posts: 0,
|
||||
stories: 0,
|
||||
reels: 0,
|
||||
carruseles: 0,
|
||||
};
|
||||
}
|
||||
const keepIds =
|
||||
mode === 'regen_selected' && plan?.pieces?.length
|
||||
? new Set(
|
||||
@@ -264,6 +328,26 @@ Regeneradas: ${regenIds.size} · Conservadas: ${
|
||||
setSource(`${j.source || 'heuristic'}+regen`);
|
||||
setRegenIds(new Set());
|
||||
}
|
||||
} else if (creatorTab === 'linkedin' && plan?.pieces?.length) {
|
||||
// Conserva IG/FB existentes; sustituye solo LinkedIn
|
||||
const keepIg = (plan.pieces || []).filter(
|
||||
(p) => !/linkedin/i.test(p.platform || '')
|
||||
);
|
||||
const newLi = (j.plan?.pieces || []).filter((p: RrssPiece) =>
|
||||
/linkedin/i.test(p.platform || '')
|
||||
);
|
||||
const merged: RrssMonthPlan = {
|
||||
...(j.plan || plan),
|
||||
pieces: [...keepIg, ...newLi].sort((a, b) =>
|
||||
a.date.localeCompare(b.date)
|
||||
),
|
||||
};
|
||||
setPlan(merged);
|
||||
setRationale(
|
||||
`${j.rationale || ''}\n\nLinkedIn regenerado (${newLi.length}); IG/FB conservados (${keepIg.length}).`
|
||||
);
|
||||
setSource(`${j.source || 'heuristic'}+li`);
|
||||
setRegenIds(new Set());
|
||||
} else {
|
||||
setPlan(j.plan);
|
||||
setRationale(j.rationale || '');
|
||||
@@ -440,7 +524,11 @@ Regeneradas: ${regenIds.size} · Conservadas: ${
|
||||
}
|
||||
}
|
||||
|
||||
const dayPieces = dayZoom ? byDate.get(dayZoom) || [] : [];
|
||||
const dayPieces = useMemo(() => {
|
||||
if (!dayZoom) return [] as RrssPiece[];
|
||||
// En el drawer del día mostramos todas las redes; el tab solo filtra el grid
|
||||
return (plan?.pieces || []).filter((p) => p.date.slice(0, 10) === dayZoom);
|
||||
}, [dayZoom, plan]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -523,6 +611,52 @@ Regeneradas: ${regenIds.size} · Conservadas: ${
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="inline-flex rounded-xl border border-gray-200 bg-gray-50 p-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCreatorTab('ig-fb')}
|
||||
className={`inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-semibold ${
|
||||
creatorTab === 'ig-fb'
|
||||
? 'bg-white text-gray-900 shadow-sm'
|
||||
: 'text-gray-500'
|
||||
}`}
|
||||
>
|
||||
<Instagram className="h-3.5 w-3.5" />
|
||||
IG / FB
|
||||
<span className="text-[10px] text-gray-400">
|
||||
{
|
||||
(plan?.pieces || []).filter(
|
||||
(p) => !/linkedin/i.test(p.platform || '')
|
||||
).length
|
||||
}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCreatorTab('linkedin')}
|
||||
className={`inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-semibold ${
|
||||
creatorTab === 'linkedin'
|
||||
? 'bg-[#7C66B8] text-white shadow-sm'
|
||||
: 'text-gray-500'
|
||||
}`}
|
||||
>
|
||||
<Linkedin className="h-3.5 w-3.5" />
|
||||
LinkedIn
|
||||
<span
|
||||
className={`text-[10px] ${
|
||||
creatorTab === 'linkedin'
|
||||
? 'text-white/70'
|
||||
: 'text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{
|
||||
(plan?.pieces || []).filter((p) =>
|
||||
/linkedin/i.test(p.platform || '')
|
||||
).length
|
||||
}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStep('pick')}
|
||||
@@ -601,6 +735,7 @@ Regeneradas: ${regenIds.size} · Conservadas: ${
|
||||
Ocultar
|
||||
</button>
|
||||
</div>
|
||||
{creatorTab === 'ig-fb' ? (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
|
||||
{(
|
||||
[
|
||||
@@ -628,6 +763,124 @@ Regeneradas: ${regenIds.size} · Conservadas: ${
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3 rounded-xl border border-[#7C66B8]/30 bg-white/80 p-3">
|
||||
<p className="text-[11px] font-bold uppercase tracking-wider text-[#7C66B8]">
|
||||
Generación LinkedIn B2B
|
||||
</p>
|
||||
<label className="text-[11px] text-gray-700 block">
|
||||
Nº de posts LinkedIn
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={40}
|
||||
value={genOptions.linkedinPosts}
|
||||
onChange={(e) =>
|
||||
setGenOptions((o) => ({
|
||||
...o,
|
||||
linkedinPosts: Number(e.target.value) || 0,
|
||||
}))
|
||||
}
|
||||
className="mt-0.5 w-full max-w-[120px] rounded-lg border border-gray-200 bg-white px-2 py-1 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold text-gray-700 mb-1">
|
||||
Fuente del copy
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{(
|
||||
[
|
||||
['from_strategy', 'Estrategia (copie listo)'],
|
||||
['ideas', 'Ideas nuevas'],
|
||||
['mixed', 'Mixto'],
|
||||
] as const
|
||||
).map(([mode, label]) => (
|
||||
<button
|
||||
key={mode}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setGenOptions((o) => ({
|
||||
...o,
|
||||
linkedinMode: mode,
|
||||
}))
|
||||
}
|
||||
className={`rounded-full px-2.5 py-1 text-[11px] font-semibold border ${
|
||||
genOptions.linkedinMode === mode
|
||||
? 'bg-[#7C66B8] text-white border-[#7C66B8]'
|
||||
: 'bg-white text-gray-600 border-gray-200'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<label className="block text-[11px] text-gray-700">
|
||||
URL estrategia LinkedIn
|
||||
<input
|
||||
type="url"
|
||||
value={genOptions.linkedinStrategyUrl || ''}
|
||||
onChange={(e) =>
|
||||
setGenOptions((o) => ({
|
||||
...o,
|
||||
linkedinStrategyUrl: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="https://4ventures.es/bps/estrategia-linkedin-bps.html"
|
||||
className="mt-0.5 w-full rounded-lg border border-gray-200 bg-white px-2 py-1.5 text-xs font-mono"
|
||||
/>
|
||||
</label>
|
||||
{liStrategyMeta?.url ? (
|
||||
<p className="text-[11px] text-gray-600">
|
||||
Detectada:{' '}
|
||||
<a
|
||||
href={liStrategyMeta.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-[#7C66B8] underline inline-flex items-center gap-0.5"
|
||||
>
|
||||
{liStrategyMeta.url.replace(/^https?:\/\//, '').slice(0, 56)}
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
{liStrategyMeta.posts_count
|
||||
? ` · ${liStrategyMeta.posts_count} posts en estrategia`
|
||||
: ''}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-[11px] text-amber-800">
|
||||
Sin estrategia en brandbook: pega la URL o guárdala en Zona de marca → LinkedIn B2B.
|
||||
</p>
|
||||
)}
|
||||
{liStrategyMeta?.themes?.length ? (
|
||||
<p className="text-[11px] text-gray-500">
|
||||
Temas: {liStrategyMeta.themes.slice(0, 5).join(' · ')}
|
||||
</p>
|
||||
) : null}
|
||||
<label className="block text-[11px] text-gray-700">
|
||||
Instrucciones LinkedIn
|
||||
<textarea
|
||||
value={genOptions.linkedinInstructions || ''}
|
||||
onChange={(e) =>
|
||||
setGenOptions((o) => ({
|
||||
...o,
|
||||
linkedinInstructions: e.target.value,
|
||||
}))
|
||||
}
|
||||
rows={2}
|
||||
placeholder="Ej: 8 posts desde la estrategia; tono C-level; CTA a demo…"
|
||||
className="mt-0.5 w-full rounded-lg border border-gray-200 bg-white px-2 py-1.5 text-xs"
|
||||
/>
|
||||
</label>
|
||||
<p className="text-[10px] text-gray-500 leading-relaxed">
|
||||
Con URL: revisa qué posts de la estrategia ya están publicados o
|
||||
agendados y programa los pendientes (copie listo). Sin URL o
|
||||
backlog agotado: crea ideas nuevas siguiendo temas/CTAs del
|
||||
brandbook y el histórico LinkedIn. Publicación API cuando configures{' '}
|
||||
<code className="bg-gray-100 px-1 rounded">LINKEDIN_ACCESS_TOKEN</code>.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold text-gray-700 mb-1">
|
||||
Días de la semana
|
||||
@@ -866,11 +1119,22 @@ Regeneradas: ${regenIds.size} · Conservadas: ${
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setForm(emptyForm(dayZoom))}
|
||||
onClick={() => {
|
||||
const f = emptyForm(dayZoom);
|
||||
if (creatorTab === 'linkedin') {
|
||||
f.platform = 'linkedin';
|
||||
f.format = 'Post';
|
||||
f.pillar = 'b2b';
|
||||
f.cta = 'Comenta tu caso · DM abierto';
|
||||
}
|
||||
setForm(f);
|
||||
}}
|
||||
className="inline-flex w-full items-center justify-center gap-2 rounded-xl bg-brand-600 px-3 py-2.5 text-sm font-semibold text-white hover:bg-brand-700"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Añadir publicación
|
||||
{creatorTab === 'linkedin'
|
||||
? 'Añadir post LinkedIn'
|
||||
: 'Añadir publicación'}
|
||||
</button>
|
||||
{dayPieces.length === 0 ? (
|
||||
<p className="text-xs text-gray-500">
|
||||
@@ -906,9 +1170,40 @@ Regeneradas: ${regenIds.size} · Conservadas: ${
|
||||
style={{ background: colorFor(p) }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-gray-600 line-clamp-3 whitespace-pre-wrap">
|
||||
<p className="text-xs text-gray-600 line-clamp-6 whitespace-pre-wrap">
|
||||
{p.body || p.hook}
|
||||
</p>
|
||||
{/linkedin/i.test(p.platform || '') ? (
|
||||
<div className="rounded-lg border border-[#7C66B8]/25 bg-[#7C66B8]/5 p-2 space-y-1.5">
|
||||
<p className="text-[10px] font-bold uppercase tracking-wider text-[#7C66B8] inline-flex items-center gap-1">
|
||||
<Linkedin className="h-3 w-3" /> Estrategia LinkedIn
|
||||
</p>
|
||||
{p.strategy_angle ? (
|
||||
<p className="text-[11px] text-gray-700">
|
||||
<span className="font-semibold">Ángulo:</span>{' '}
|
||||
{p.strategy_angle}
|
||||
</p>
|
||||
) : null}
|
||||
{(p.strategy_url || p.meta_line) && (
|
||||
<a
|
||||
href={
|
||||
(p.meta_line?.includes('http')
|
||||
? p.meta_line
|
||||
: p.strategy_url) || '#'
|
||||
}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-[11px] text-[#7C66B8] underline inline-flex items-center gap-1"
|
||||
>
|
||||
Ver en página de estrategia
|
||||
{p.strategy_post_id
|
||||
? ` (#${p.strategy_post_id})`
|
||||
: ''}
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
{p.interactive_story &&
|
||||
p.interactive_story !== 'none' ? (
|
||||
<p className="text-[10px] font-semibold text-amber-800 bg-amber-50 border border-amber-200 rounded-lg px-2 py-1">
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Filter,
|
||||
Undo2,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import { PageHeader, Surface, EmptyState } from '@/components/ui';
|
||||
|
||||
@@ -52,6 +54,27 @@ function entityLabel(t: string): string {
|
||||
return ENTITY_LABELS[t] || t;
|
||||
}
|
||||
|
||||
function canUndo(row: Row): boolean {
|
||||
if (row.action === 'undo') return false;
|
||||
const d = row.detail || {};
|
||||
if (d.undone === true) return false;
|
||||
if (row.entity_type !== 'brandbook') return false;
|
||||
if (d.revertable === true) return true;
|
||||
if (
|
||||
d.revert_patch &&
|
||||
typeof d.revert_patch === 'object' &&
|
||||
Object.keys(d.revert_patch as object).length > 0
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
// Legacy: updates con lista de cambios
|
||||
return (
|
||||
row.action === 'update' &&
|
||||
Array.isArray(d.changes) &&
|
||||
(d.changes as unknown[]).length > 0
|
||||
);
|
||||
}
|
||||
|
||||
function DetailBlock({ detail }: { detail: Record<string, unknown> }) {
|
||||
const changes = Array.isArray(detail.changes)
|
||||
? (detail.changes as { field?: string; from?: unknown; to?: unknown }[])
|
||||
@@ -141,6 +164,8 @@ export default function ChangeHistoryView({
|
||||
const [entityType, setEntityType] = useState('');
|
||||
const [portfolioWide, setPortfolioWide] = useState(false);
|
||||
const [openId, setOpenId] = useState<string | null>(null);
|
||||
const [undoingId, setUndoingId] = useState<string | null>(null);
|
||||
const [undoMsg, setUndoMsg] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -173,12 +198,42 @@ export default function ChangeHistoryView({
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
async function undoChange(row: Row) {
|
||||
if (!canUndo(row) || undoingId) return;
|
||||
const ok = window.confirm(
|
||||
`¿Deshacer este cambio?\n\n«${row.summary}»\n\nSe restaurarán los valores anteriores en Zona de marca y el deshacer quedará registrado en el historial.`
|
||||
);
|
||||
if (!ok) return;
|
||||
setUndoingId(row.id);
|
||||
setUndoMsg(null);
|
||||
try {
|
||||
const r = await fetch('/api/change-history/undo', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id: row.id }),
|
||||
});
|
||||
const j = await r.json();
|
||||
if (!r.ok || j.ok === false) {
|
||||
setUndoMsg(j.error || 'No se pudo deshacer');
|
||||
return;
|
||||
}
|
||||
setUndoMsg(
|
||||
`Deshecho: ${(j.restored_fields || []).length} campo(s) restaurados. El registro original sigue en el historial.`
|
||||
);
|
||||
await load();
|
||||
} catch (e) {
|
||||
setUndoMsg(e instanceof Error ? e.message : 'Error de red');
|
||||
} finally {
|
||||
setUndoingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
eyebrow="Auditoría"
|
||||
title="Historial de cambios"
|
||||
description="Usuario, fecha, cambio y detalle de lo que se modifica en el Centro de Mando."
|
||||
description="Usuario, fecha, cambio y detalle. Puedes deshacer cambios de Zona de marca; el deshacer también queda registrado."
|
||||
actions={
|
||||
<button
|
||||
type="button"
|
||||
@@ -233,6 +288,12 @@ export default function ChangeHistoryView({
|
||||
</Surface>
|
||||
) : null}
|
||||
|
||||
{undoMsg ? (
|
||||
<Surface className="border-sky-200 bg-sky-50">
|
||||
<p className="text-sm text-sky-900">{undoMsg}</p>
|
||||
</Surface>
|
||||
) : null}
|
||||
|
||||
<Surface padded={false}>
|
||||
{loading && !rows.length ? (
|
||||
<div className="p-8 text-sm text-gray-500 flex items-center gap-2">
|
||||
@@ -292,19 +353,58 @@ export default function ChangeHistoryView({
|
||||
</div>
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{row.summary}
|
||||
{row.detail?.undone === true ? (
|
||||
<span className="ml-2 text-[10px] font-semibold uppercase tracking-wide text-amber-700 bg-amber-50 border border-amber-200 rounded-full px-2 py-0.5">
|
||||
Deshecho
|
||||
</span>
|
||||
) : null}
|
||||
{row.action === 'undo' ? (
|
||||
<span className="ml-2 text-[10px] font-semibold uppercase tracking-wide text-violet-700 bg-violet-50 border border-violet-200 rounded-full px-2 py-0.5">
|
||||
Undo
|
||||
</span>
|
||||
) : null}
|
||||
</p>
|
||||
</div>
|
||||
<History className="w-4 h-4 text-gray-300 shrink-0 mt-1" />
|
||||
</div>
|
||||
</button>
|
||||
{open ? (
|
||||
<div className="px-4 md:px-5 pb-4 pl-11 md:pl-12">
|
||||
<div className="px-4 md:px-5 pb-4 pl-11 md:pl-12 space-y-3">
|
||||
<DetailBlock detail={row.detail || {}} />
|
||||
{row.source ? (
|
||||
<p className="mt-2 text-[11px] text-gray-400">
|
||||
<p className="text-[11px] text-gray-400">
|
||||
Origen: {row.source}
|
||||
</p>
|
||||
) : null}
|
||||
{canUndo(row) ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={undoingId === row.id}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void undoChange(row);
|
||||
}}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-amber-300 bg-amber-50 px-3 py-1.5 text-xs font-semibold text-amber-900 hover:bg-amber-100 disabled:opacity-60"
|
||||
>
|
||||
{undoingId === row.id ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<Undo2 className="w-3.5 h-3.5" />
|
||||
)}
|
||||
Deshacer este cambio
|
||||
</button>
|
||||
) : row.detail?.undone === true ? (
|
||||
<p className="text-[11px] text-amber-800">
|
||||
Ya deshecho
|
||||
{typeof row.detail.undone_by === 'string'
|
||||
? ` por ${row.detail.undone_by}`
|
||||
: ''}
|
||||
{typeof row.detail.undone_at === 'string'
|
||||
? ` · ${fmtDate(row.detail.undone_at)}`
|
||||
: ''}
|
||||
. El registro se conserva en el historial.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</li>
|
||||
|
||||
@@ -1713,6 +1713,9 @@ function DetailModal({
|
||||
const [chFb, setChFb] = useState(
|
||||
(item.channels || ['instagram', 'facebook']).includes('facebook')
|
||||
);
|
||||
const [chLi, setChLi] = useState(
|
||||
(item.channels || []).includes('linkedin')
|
||||
);
|
||||
const [editTitle, setEditTitle] = useState(item.title);
|
||||
const [editCaption, setEditCaption] = useState(item.caption || '');
|
||||
const [syncCal, setSyncCal] = useState(Boolean(item.calendar_piece_id));
|
||||
@@ -1735,6 +1738,7 @@ function DetailModal({
|
||||
const ch = item.channels || ['instagram', 'facebook'];
|
||||
setChIg(ch.includes('instagram'));
|
||||
setChFb(ch.includes('facebook'));
|
||||
setChLi(ch.includes('linkedin'));
|
||||
setEditTitle(item.title);
|
||||
setEditCaption(item.caption || '');
|
||||
setSyncCal(Boolean(item.calendar_piece_id));
|
||||
@@ -1764,6 +1768,7 @@ function DetailModal({
|
||||
const channels: PublishChannel[] = [];
|
||||
if (chIg) channels.push('instagram');
|
||||
if (chFb) channels.push('facebook');
|
||||
if (chLi) channels.push('linkedin');
|
||||
const igIso = schedIg ? new Date(schedIg).toISOString() : null;
|
||||
const fbIso = schedFb ? new Date(schedFb).toISOString() : null;
|
||||
const primary =
|
||||
@@ -2062,6 +2067,17 @@ function DetailModal({
|
||||
/>
|
||||
Facebook
|
||||
</label>
|
||||
<label className="inline-flex items-center gap-1.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={chLi}
|
||||
onChange={(e) => setChLi(e.target.checked)}
|
||||
/>
|
||||
LinkedIn
|
||||
<span className="text-[10px] text-gray-500">
|
||||
(API key pendiente)
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
{chIg && chFb ? (
|
||||
<label className="flex items-start gap-2 text-[11px] text-gray-800">
|
||||
|
||||
@@ -189,13 +189,17 @@ export default function DashboardShell({
|
||||
|
||||
const visibleNav = NAV_ITEMS.filter((item) => moduleOn(item.moduleId));
|
||||
|
||||
const showClientBrand = Boolean(clientLogoUrl);
|
||||
const title = showClientBrand ? brandLabel || company : '4VENTURES';
|
||||
const subtitle = showClientBrand
|
||||
? isAgency
|
||||
? 'Vista cliente'
|
||||
: 'Portal cliente'
|
||||
: 'Centro de Mando';
|
||||
// Agencia siempre mantiene identidad 4Ventures (aunque esté "viendo" un cliente).
|
||||
const title = isAgency
|
||||
? '4VENTURES'
|
||||
: clientLogoUrl
|
||||
? brandLabel || company
|
||||
: '4VENTURES';
|
||||
const subtitle = isAgency
|
||||
? viewName
|
||||
? `Agencia · viendo ${viewName}`
|
||||
: 'Centro de Mando'
|
||||
: 'Portal cliente';
|
||||
|
||||
const sidebar = (
|
||||
<div className="flex h-full flex-col">
|
||||
@@ -317,8 +321,15 @@ export default function DashboardShell({
|
||||
<span className="text-[10px] text-gray-500 capitalize truncate">
|
||||
{company}
|
||||
</span>
|
||||
<span className="text-[9px] uppercase tracking-wider font-semibold px-1.5 py-0.5 rounded bg-brand-50 text-brand-600 shrink-0">
|
||||
{role}
|
||||
<span
|
||||
className="text-[9px] uppercase tracking-wider font-semibold px-1.5 py-0.5 rounded bg-brand-50 text-brand-600 shrink-0"
|
||||
title={role}
|
||||
>
|
||||
{role === 'admin'
|
||||
? 'Agencia'
|
||||
: role === 'team'
|
||||
? 'Equipo'
|
||||
: 'Cliente'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -59,6 +59,12 @@ export type RrssPiece = {
|
||||
pipeline_id?: string;
|
||||
cover_path?: string | null;
|
||||
slide_paths?: string[];
|
||||
/** URL de la estrategia LinkedIn de la que sale / se inspira la pieza. */
|
||||
strategy_url?: string;
|
||||
/** Ancla en la página (ej. post-12). */
|
||||
strategy_post_id?: string;
|
||||
/** Ángulo / briefing de la estrategia. */
|
||||
strategy_angle?: string;
|
||||
};
|
||||
|
||||
export type RrssMonthPlan = {
|
||||
@@ -853,6 +859,43 @@ export default function RrssMonthView({
|
||||
{selected.hashtags && (
|
||||
<p className="text-xs text-sky-400 font-mono">{selected.hashtags}</p>
|
||||
)}
|
||||
{/linkedin/i.test(selected.platform || '') ? (
|
||||
<div className="rounded-xl border border-[#7C66B8]/30 bg-[#7C66B8]/5 p-3 space-y-2">
|
||||
<p className="text-xs font-bold uppercase tracking-wider text-[#7C66B8] inline-flex items-center gap-1.5">
|
||||
<Linkedin className="w-3.5 h-3.5" /> Contenido estrategia LinkedIn
|
||||
</p>
|
||||
{selected.strategy_angle ? (
|
||||
<p className="text-xs text-gray-800">
|
||||
<span className="font-semibold">Ángulo:</span>{' '}
|
||||
{selected.strategy_angle}
|
||||
</p>
|
||||
) : null}
|
||||
{(selected.strategy_url ||
|
||||
(selected.meta_line && selected.meta_line.includes('http'))) && (
|
||||
<a
|
||||
href={
|
||||
selected.meta_line?.includes('http')
|
||||
? selected.meta_line.trim()
|
||||
: `${selected.strategy_url || ''}${
|
||||
selected.strategy_post_id
|
||||
? `#${selected.strategy_post_id}`
|
||||
: ''
|
||||
}`
|
||||
}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 rounded-lg bg-[#7C66B8] px-3 py-1.5 text-xs font-bold text-white hover:opacity-95"
|
||||
>
|
||||
Abrir en estrategia 4Ventures{' '}
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
)}
|
||||
<p className="text-[11px] text-gray-500">
|
||||
Copy listo para publicar. La publicación automática vía API
|
||||
LinkedIn se activará cuando configures la API key.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{canCollaborate && (
|
||||
<div className="rounded-xl border border-gray-200 p-3 space-y-2">
|
||||
|
||||
@@ -35,22 +35,75 @@ export function isAgencyRole(role: string | undefined | null): boolean {
|
||||
return role === 'admin' || role === 'team';
|
||||
}
|
||||
|
||||
/** Operadores 4Ventures conocidos (fallback si el JWT pierde app_metadata.role). */
|
||||
export const AGENCY_OPERATOR_EMAILS = new Set(
|
||||
[
|
||||
'marketing@4ventures.es',
|
||||
'marcos@4ventures.es',
|
||||
'belen@4ventures.es',
|
||||
'rebecca@4ventures.es',
|
||||
'victor@4ventures.es',
|
||||
'agencia@4ventures.es',
|
||||
'ops@4ventures.es',
|
||||
].map((e) => e.toLowerCase())
|
||||
);
|
||||
|
||||
/**
|
||||
* Authorization role. Privileged roles (admin/team) ONLY from app_metadata
|
||||
* so clients cannot self-elevate via user_metadata.updateUser.
|
||||
* Emails @4ventures.es son agencia salvo que app_metadata.role === 'client'
|
||||
* (cliente real con correo corporativo).
|
||||
*/
|
||||
export function isAgencyOperatorEmail(email: string | null | undefined): boolean {
|
||||
const e = String(email || '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (!e) return false;
|
||||
if (AGENCY_OPERATOR_EMAILS.has(e)) return true;
|
||||
return e.endsWith('@4ventures.es');
|
||||
}
|
||||
|
||||
/** Etiqueta UI del rol (evita confusión "admin" ≈ admin@…). */
|
||||
export function roleLabel(role: string | undefined | null): string {
|
||||
if (role === 'admin') return 'Agencia';
|
||||
if (role === 'team') return 'Equipo';
|
||||
return 'Cliente';
|
||||
}
|
||||
|
||||
/**
|
||||
* Authorization role. Privileged roles prefer app_metadata so clients cannot
|
||||
* self-elevate via user_metadata.updateUser. Fallback:
|
||||
* - operadores @4ventures.es → team
|
||||
* - user_metadata.role=client → client
|
||||
*/
|
||||
export function userRole(user: {
|
||||
email?: string | null;
|
||||
app_metadata?: Record<string, unknown>;
|
||||
user_metadata?: Record<string, unknown>;
|
||||
} | null): string {
|
||||
const app = (user?.app_metadata?.role as string | undefined)?.trim();
|
||||
if (app === 'admin' || app === 'team' || app === 'client') return app;
|
||||
// Legacy clients may only have user_metadata.role=client — never admin/team.
|
||||
if (app === 'admin' || app === 'team') return app;
|
||||
// Cliente explícito en app_metadata gana (aunque el email sea @4ventures.es).
|
||||
if (app === 'client') return 'client';
|
||||
|
||||
// Fallback operadores 4V (JWT incompleto / provision antiguo).
|
||||
if (isAgencyOperatorEmail(user?.email)) return 'team';
|
||||
|
||||
// Legacy clients may only have user_metadata.role=client — never elevate from um.
|
||||
const um = (user?.user_metadata?.role as string | undefined)?.trim();
|
||||
if (um === 'client') return 'client';
|
||||
return 'client';
|
||||
}
|
||||
|
||||
/** Clear agency "zoomed into client" cookies (logout / leave client view). */
|
||||
export function clearedViewTenantCookieOpts(secure: boolean) {
|
||||
return {
|
||||
path: '/',
|
||||
maxAge: 0,
|
||||
sameSite: 'lax' as const,
|
||||
secure,
|
||||
httpOnly: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function homeTenantId(user: {
|
||||
app_metadata?: Record<string, unknown>;
|
||||
user_metadata?: Record<string, unknown>;
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
/** Drive folder names created (or reserved) by the app under the client root. */
|
||||
const APP_DRIVE_FOLDER_RES =
|
||||
/^(Pipeline-creatividades_|Calendarios?[_\s-]?|Creatividades[_\s-]?|Ads[_\s-]?creatividades|Centro[_\s-]?de[_\s-]?Mando|4Ventures[_\s-]?Portal|Portal[_\s-]?app)/i;
|
||||
/^(Creatividades-Ads_|Creatividades-RRSS_|Pipeline-creatividades_|Calendarios?[_\s-]?|Creatividades[_\s-]?|Ads[_\s-]?creatividades|Centro[_\s-]?de[_\s-]?Mando|4Ventures[_\s-]?Portal|Portal[_\s-]?app)/i;
|
||||
|
||||
/** Portal asset_files.folder prefixes written by the app (not free-form agency dumps). */
|
||||
const APP_PORTAL_FOLDER_PREFIXES = [
|
||||
@@ -16,6 +16,8 @@ const APP_PORTAL_FOLDER_PREFIXES = [
|
||||
'calendario',
|
||||
'pipeline',
|
||||
'pipeline-creatividades',
|
||||
'creatividades-ads',
|
||||
'creatividades-rrss',
|
||||
'ads',
|
||||
'meta',
|
||||
'centro-de-mando',
|
||||
|
||||
@@ -2011,8 +2011,21 @@ export function brandbookToPrompt(book: Brandbook): string {
|
||||
`Ejemplos copy real (ads/IG/FB/LI): ${book.example_good.slice(0, 6).join(' | ')}`,
|
||||
(book.preferred_angles?.length || 0) > 0 &&
|
||||
`Ángulos campaña: ${(book.preferred_angles || []).slice(0, 8).join(' · ')}`,
|
||||
book.linkedin_strategy_url &&
|
||||
`URL estrategia LinkedIn: ${book.linkedin_strategy_url}`,
|
||||
book.linkedin_themes.length &&
|
||||
`LinkedIn: ${book.linkedin_themes.join(', ')}`,
|
||||
`Temas LinkedIn: ${book.linkedin_themes.join(', ')}`,
|
||||
book.linkedin_formats.length &&
|
||||
`Formatos LinkedIn: ${book.linkedin_formats.join(', ')}`,
|
||||
book.thought_leadership.length &&
|
||||
`Thought leadership: ${book.thought_leadership.join(', ')}`,
|
||||
book.buyer_roles.length &&
|
||||
`Buyer roles LI: ${book.buyer_roles.join(', ')}`,
|
||||
book.case_study_angles.length &&
|
||||
`Ángulos caso LI: ${book.case_study_angles.join(', ')}`,
|
||||
book.linkedin_cta.length &&
|
||||
`CTAs LinkedIn: ${book.linkedin_cta.join(', ')}`,
|
||||
book.abm_notes && `ABM LinkedIn: ${book.abm_notes.slice(0, 600)}`,
|
||||
book.blog_themes.length && `Blog: ${book.blog_themes.join(', ')}`,
|
||||
book.claims_forbidden.length &&
|
||||
`Prohibido: ${book.claims_forbidden.join('; ')}`,
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
*/
|
||||
export type WeekdayKey = 1 | 2 | 3 | 4 | 5 | 6 | 0; // Lun=1 … Dom=0 (JS getUTCDay)
|
||||
|
||||
export type LinkedInGenMode = 'from_strategy' | 'ideas' | 'mixed';
|
||||
|
||||
export type CalendarGenOptions = {
|
||||
/** Nº de posts feed (Post estático). */
|
||||
posts: number;
|
||||
@@ -12,6 +14,19 @@ export type CalendarGenOptions = {
|
||||
reels: number;
|
||||
/** Nº de Carruseles. */
|
||||
carruseles: number;
|
||||
/**
|
||||
* Nº de piezas LinkedIn (posts B2B listos para publicar).
|
||||
* 0 = no generar LinkedIn en este preview.
|
||||
*/
|
||||
linkedinPosts: number;
|
||||
/**
|
||||
* from_strategy: copie de la URL de estrategia.
|
||||
* ideas: copy nuevo al tono de la estrategia / brandbook.
|
||||
* mixed: mezcla ambos.
|
||||
*/
|
||||
linkedinMode: LinkedInGenMode;
|
||||
/** Override de URL de estrategia LinkedIn (si vacío → brandbook / slug conocido). */
|
||||
linkedinStrategyUrl?: string;
|
||||
/**
|
||||
* Días de la semana permitidos (JS: 0=Dom … 6=Sáb).
|
||||
* Por defecto Lun–Vie.
|
||||
@@ -29,6 +44,8 @@ export type CalendarGenOptions = {
|
||||
* (modo regen_selected).
|
||||
*/
|
||||
regenInstructions?: string;
|
||||
/** Instrucciones solo para piezas LinkedIn. */
|
||||
linkedinInstructions?: string;
|
||||
};
|
||||
|
||||
export const DEFAULT_GEN_OPTIONS: CalendarGenOptions = {
|
||||
@@ -36,10 +53,14 @@ export const DEFAULT_GEN_OPTIONS: CalendarGenOptions = {
|
||||
stories: 8,
|
||||
reels: 6,
|
||||
carruseles: 4,
|
||||
linkedinPosts: 0,
|
||||
linkedinMode: 'from_strategy',
|
||||
linkedinStrategyUrl: '',
|
||||
weekdays: [1, 2, 3, 4, 5],
|
||||
allowSameDayMix: true,
|
||||
instructions: '',
|
||||
regenInstructions: '',
|
||||
linkedinInstructions: '',
|
||||
};
|
||||
|
||||
export function normalizeGenOptions(
|
||||
@@ -51,15 +72,26 @@ export function normalizeGenOptions(
|
||||
const weekdays = Array.isArray(base.weekdays)
|
||||
? [...new Set(base.weekdays.map((d) => Number(d)).filter((d) => d >= 0 && d <= 6))]
|
||||
: DEFAULT_GEN_OPTIONS.weekdays;
|
||||
const modeRaw = String(base.linkedinMode || 'from_strategy').toLowerCase();
|
||||
const linkedinMode: LinkedInGenMode =
|
||||
modeRaw === 'ideas' || modeRaw === 'mixed' ? modeRaw : 'from_strategy';
|
||||
return {
|
||||
posts: clamp(base.posts),
|
||||
stories: clamp(base.stories),
|
||||
reels: clamp(base.reels),
|
||||
carruseles: clamp(base.carruseles),
|
||||
linkedinPosts: clamp(base.linkedinPosts),
|
||||
linkedinMode,
|
||||
linkedinStrategyUrl: String(base.linkedinStrategyUrl || '')
|
||||
.trim()
|
||||
.slice(0, 500),
|
||||
weekdays: weekdays.length ? weekdays : DEFAULT_GEN_OPTIONS.weekdays,
|
||||
allowSameDayMix: base.allowSameDayMix !== false,
|
||||
instructions: String(base.instructions || '').trim().slice(0, 4000),
|
||||
regenInstructions: String(base.regenInstructions || '').trim().slice(0, 4000),
|
||||
linkedinInstructions: String(base.linkedinInstructions || '')
|
||||
.trim()
|
||||
.slice(0, 4000),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
/** Shared brand DNA + title helpers for calendar strategy / generate. */
|
||||
|
||||
import {
|
||||
brandSiblingNote,
|
||||
resolveBrandSlug,
|
||||
} from '@/lib/tenant-brand-aliases';
|
||||
|
||||
export type TenantBrandBrief = {
|
||||
slug: string;
|
||||
label: string;
|
||||
@@ -11,19 +16,67 @@ export type TenantBrandBrief = {
|
||||
};
|
||||
|
||||
const BRANDS: Record<string, Omit<TenantBrandBrief, 'slug' | 'label'>> = {
|
||||
'talento-y-deporte': {
|
||||
vertical: 'Educación deportiva / becas USA',
|
||||
tone: 'Inspirador, cercano, ambicioso; historias de alumnos',
|
||||
/** TYD — Talento y Deporte (deportivo; hermano de TSP) */
|
||||
tyd: {
|
||||
vertical: 'Becas deportivas USA / educación deportiva',
|
||||
tone: 'Inspirador, cercano, ambicioso; historias de atletas-alumnos',
|
||||
pillars: [
|
||||
'nuevo-alumno',
|
||||
'beca-academica',
|
||||
'beca-deportiva',
|
||||
'campus',
|
||||
'objeciones',
|
||||
'tips-ingles',
|
||||
'vida-deportiva',
|
||||
],
|
||||
formats: { post: 0.35, reel: 0.25, stories: 0.3, carrusel: 0.1 },
|
||||
notes: 'Series Nuevo alumno, objeciones, campus USA, English tips.',
|
||||
formats: { post: 0.32, reel: 0.28, stories: 0.28, carrusel: 0.12 },
|
||||
notes:
|
||||
'TYD · Talento y Deporte (marca deportiva, separada de TSP Scholar).',
|
||||
},
|
||||
/** TSP — Talento Scholar Program (académico; hermano de TYD) */
|
||||
tsp: {
|
||||
vertical: 'Becas académicas USA / Scholar',
|
||||
tone: 'Orientador, serio-cercano, aspiracional académico',
|
||||
pillars: [
|
||||
'beca-academica',
|
||||
'admisiones',
|
||||
'campus',
|
||||
'financiacion',
|
||||
'objeciones',
|
||||
'casos-exito',
|
||||
],
|
||||
formats: { post: 0.35, reel: 0.22, stories: 0.28, carrusel: 0.15 },
|
||||
notes:
|
||||
'TSP · Talento Scholar Program (marca académica, separada de TYD Deporte).',
|
||||
},
|
||||
/** DTATF — De Tú a Tú Formación (hermano de ECP) */
|
||||
dtatf: {
|
||||
vertical: 'Formación profesional / empleabilidad',
|
||||
tone: 'Cercano, práctico, motivador; transformación laboral',
|
||||
pillars: [
|
||||
'cursos',
|
||||
'empleabilidad',
|
||||
'testimonios',
|
||||
'convocatorias',
|
||||
'tips-formacion',
|
||||
],
|
||||
formats: { post: 0.3, reel: 0.28, stories: 0.3, carrusel: 0.12 },
|
||||
notes:
|
||||
'DTATF · De Tú a Tú Formación (marca distinta de ECP).',
|
||||
},
|
||||
/** ECP — Escuela de Cuidadores Profesionales (hermano de DTATF) */
|
||||
ecp: {
|
||||
vertical: 'Escuela de cuidadores profesionales',
|
||||
tone: 'Humano, profesional, cercano; vocación de cuidado',
|
||||
pillars: [
|
||||
'formacion-cuidadores',
|
||||
'empleabilidad',
|
||||
'dia-a-dia',
|
||||
'testimonios',
|
||||
'convocatorias',
|
||||
],
|
||||
formats: { post: 0.28, reel: 0.3, stories: 0.3, carrusel: 0.12 },
|
||||
notes:
|
||||
'ECP · Escuela de Cuidadores Profesionales (marca distinta de DTATF). IG @escuelaecp.',
|
||||
},
|
||||
'halal-safety': {
|
||||
vertical: 'Certificación Halal / exportación',
|
||||
@@ -55,6 +108,21 @@ const BRANDS: Record<string, Omit<TenantBrandBrief, 'slug' | 'label'>> = {
|
||||
notes:
|
||||
'ADN BPS: FALSE FRIEND, SLANG, Same word, canción, TV shows, matriculación Madrid/Málaga, Trinity/IELTS — crear ángulos nuevos, no repetir títulos.',
|
||||
},
|
||||
'agua-miia': {
|
||||
vertical: 'Agua mineral / FMCG',
|
||||
tone: 'Fresco, natural, premium accesible; bienestar sin postureo',
|
||||
pillars: [
|
||||
'producto',
|
||||
'estilo-de-vida',
|
||||
'origen',
|
||||
'sostenibilidad',
|
||||
'recetas-y-usos',
|
||||
'comunidad',
|
||||
],
|
||||
formats: { post: 0.28, reel: 0.32, stories: 0.28, carrusel: 0.12 },
|
||||
notes:
|
||||
'ADN Agua Miia: hidratación, origen del agua, rituales diarios, visual limpio y producto hero.',
|
||||
},
|
||||
'blue-parrot-school': {
|
||||
vertical: 'Escuela de idiomas',
|
||||
tone: 'Educativo, pop culture, series recurrentes',
|
||||
@@ -83,17 +151,66 @@ const BRANDS: Record<string, Omit<TenantBrandBrief, 'slug' | 'label'>> = {
|
||||
formats: { post: 0.35, reel: 0.25, stories: 0.2, carrusel: 0.2 },
|
||||
notes: 'Contenido de agencia.',
|
||||
},
|
||||
'delgado-abogados': {
|
||||
vertical: 'Despacho de abogados',
|
||||
tone: 'Serio, cercano, autoridad legal sin frialdad',
|
||||
pillars: ['areas-practica', 'casos', 'educativo-legal', 'confianza'],
|
||||
formats: { post: 0.4, reel: 0.2, stories: 0.2, carrusel: 0.2 },
|
||||
notes: 'Delgado Abogados — claims legales prudentes, sin garantías de resultado.',
|
||||
},
|
||||
'equinsa-parking': {
|
||||
vertical: 'Parking / movilidad',
|
||||
tone: 'Práctico, claro, orientado a conveniencia',
|
||||
pillars: ['producto', 'ubicaciones', 'tips-parking', 'b2b'],
|
||||
formats: { post: 0.35, reel: 0.25, stories: 0.25, carrusel: 0.15 },
|
||||
notes: 'Equinsa Parking — plazas, app, B2B y tipología de aparcamiento.',
|
||||
},
|
||||
'fbm-abogados': {
|
||||
vertical: 'Despacho de abogados / herencias',
|
||||
tone: 'Empático, claro, autoridad en derecho sucesorio',
|
||||
pillars: ['herencias', 'educativo-legal', 'casos', 'confianza'],
|
||||
formats: { post: 0.4, reel: 0.15, stories: 0.25, carrusel: 0.2 },
|
||||
notes: 'FBM Abogados — herencias y sucesiones; tono humano + rigor.',
|
||||
},
|
||||
'la-freseria': {
|
||||
vertical: 'Fresa / agroalimentación / retail',
|
||||
tone: 'Fresco, apetitoso, cercano, estacional',
|
||||
pillars: ['producto', 'origen', 'recetas', 'temporada', 'tienda'],
|
||||
formats: { post: 0.28, reel: 0.32, stories: 0.28, carrusel: 0.12 },
|
||||
notes: 'La Fresería — producto hero, temporada y consumo.',
|
||||
},
|
||||
'virginia-molet': {
|
||||
vertical: 'Marca personal / profesional',
|
||||
tone: 'Cercano, aspiracional, auténtico',
|
||||
pillars: ['marca-personal', 'tips', 'bastidores', 'comunidad'],
|
||||
formats: { post: 0.3, reel: 0.35, stories: 0.25, carrusel: 0.1 },
|
||||
notes: 'Virginia Molet — voz personal; ajustar vertical exacta en Zona de marca.',
|
||||
},
|
||||
'wine-advisors': {
|
||||
vertical: 'Vino / asesoría enológica',
|
||||
tone: 'Experto, cercano, disfrute sin snobismo',
|
||||
pillars: ['maridaje', 'educativo-vino', 'seleccion', 'experiencias'],
|
||||
formats: { post: 0.35, reel: 0.25, stories: 0.25, carrusel: 0.15 },
|
||||
notes: 'Wine Advisors — catas, maridajes y selección; IG wine.advisor.',
|
||||
},
|
||||
};
|
||||
|
||||
export function brandBrief(slug: string, label: string): TenantBrandBrief {
|
||||
const base = BRANDS[slug] || {
|
||||
const key = resolveBrandSlug(slug);
|
||||
const base = BRANDS[key] || {
|
||||
vertical: 'Marca / servicios',
|
||||
tone: 'Profesional y claro',
|
||||
pillars: ['educativo', 'producto', 'comunidad', 'promo'],
|
||||
formats: { post: 0.3, reel: 0.25, stories: 0.3, carrusel: 0.15 },
|
||||
notes: 'Ajustar al ADN del cliente.',
|
||||
};
|
||||
return { slug, label, ...base };
|
||||
const sib = brandSiblingNote(key);
|
||||
return {
|
||||
slug: key,
|
||||
label,
|
||||
...base,
|
||||
notes: sib ? `${base.notes} ${sib}` : base.notes,
|
||||
};
|
||||
}
|
||||
|
||||
export function cleanEditorialTitle(raw: string): string {
|
||||
|
||||
@@ -42,12 +42,17 @@ export type GeneratePreviewInput = {
|
||||
avoidTitles?: string[];
|
||||
/** Cantidades, días, mix e instrucciones del operador. */
|
||||
genOptions?: Partial<CalendarGenOptions> | null;
|
||||
/** Contexto LinkedIn ya cargado (URL + posts parseados + prompt). */
|
||||
linkedinStrategyUrl?: string;
|
||||
linkedinPrompt?: string;
|
||||
linkedinStrategyPosts?: import('@/lib/linkedin-strategy').LinkedInStrategyPost[];
|
||||
};
|
||||
|
||||
export type GeneratePreviewResult = {
|
||||
plan: RrssMonthPlan;
|
||||
rationale: string;
|
||||
source: 'anthropic' | 'strategy' | 'heuristic';
|
||||
linkedinMeta?: import('@/lib/calendar-strategy').LinkedInScheduleMeta;
|
||||
};
|
||||
|
||||
function colorFor(format: string, kind: string, platform: string): string {
|
||||
@@ -89,6 +94,10 @@ async function anthropicStrategist(
|
||||
input.brandbook,
|
||||
input.publishedPosts || []
|
||||
);
|
||||
if (input.linkedinPrompt) dna.linkedinPrompt = input.linkedinPrompt;
|
||||
if (input.linkedinStrategyUrl) {
|
||||
dna.linkedinStrategyUrl = input.linkedinStrategyUrl;
|
||||
}
|
||||
const go = normalizeGenOptions(input.genOptions);
|
||||
const trends = monthTrendBeats(input.ym, dna.brand.vertical);
|
||||
const prompt = strategistSystemPrompt({
|
||||
@@ -97,14 +106,16 @@ async function anthropicStrategist(
|
||||
dna,
|
||||
trends,
|
||||
avoidTitles: input.avoidTitles || [],
|
||||
genOptions: go,
|
||||
genOptions: {
|
||||
...go,
|
||||
linkedinMode: go.linkedinMode,
|
||||
linkedinInstructions: go.linkedinInstructions,
|
||||
},
|
||||
});
|
||||
const igTotal = go.posts + go.stories + go.reels + go.carruseles;
|
||||
const minPieces = Math.max(
|
||||
3,
|
||||
Math.min(
|
||||
8,
|
||||
go.posts + go.stories + go.reels + go.carruseles
|
||||
)
|
||||
1,
|
||||
Math.min(8, Math.max(igTotal + go.linkedinPosts, go.linkedinPosts || igTotal || 3))
|
||||
);
|
||||
|
||||
try {
|
||||
@@ -148,13 +159,35 @@ async function anthropicStrategist(
|
||||
}))
|
||||
.filter((s) => s.text)
|
||||
: undefined;
|
||||
const strategy_url =
|
||||
String(
|
||||
(p as { strategy_url?: string }).strategy_url ||
|
||||
(platform.includes('linkedin')
|
||||
? input.linkedinStrategyUrl || ''
|
||||
: '')
|
||||
).trim() || undefined;
|
||||
const strategy_post_id =
|
||||
String(
|
||||
(p as { strategy_post_id?: string }).strategy_post_id || ''
|
||||
).trim() || undefined;
|
||||
const strategy_angle =
|
||||
String(
|
||||
(p as { strategy_angle?: string }).strategy_angle || ''
|
||||
).trim() || undefined;
|
||||
return {
|
||||
id: newPieceId(date, title),
|
||||
date,
|
||||
time: String(p.time || '13:00').slice(0, 5),
|
||||
platform,
|
||||
format,
|
||||
pillar: String(p.pillar || (kind === 'story' ? 'stories' : 'feed')),
|
||||
pillar: String(
|
||||
p.pillar ||
|
||||
(platform.includes('linkedin')
|
||||
? 'b2b'
|
||||
: kind === 'story'
|
||||
? 'stories'
|
||||
: 'feed')
|
||||
),
|
||||
title: title.slice(0, 160),
|
||||
hook: String(p.hook || title).slice(0, 180),
|
||||
body: String(p.body || title),
|
||||
@@ -172,23 +205,48 @@ async function anthropicStrategist(
|
||||
interactive_story: p.interactive_story
|
||||
? String(p.interactive_story).slice(0, 40)
|
||||
: undefined,
|
||||
strategy_url,
|
||||
strategy_post_id,
|
||||
strategy_angle,
|
||||
meta_line:
|
||||
strategy_url
|
||||
? `${strategy_url}${strategy_post_id ? `#${strategy_post_id}` : ''}`
|
||||
: undefined,
|
||||
} as RrssPiece;
|
||||
})
|
||||
.filter(Boolean) as RrssPiece[];
|
||||
|
||||
if (pieces.length < minPieces) return fallback;
|
||||
// LinkedIn: siempre el backlog determinista (publicados vs pendientes).
|
||||
// Claude puede inventar/repetir; sustituimos piezas LI por las del fallback.
|
||||
const wantLi = go.linkedinPosts || 0;
|
||||
if (wantLi > 0) {
|
||||
const liFromStrategy = (fallback.plan.pieces || []).filter((p) =>
|
||||
/linkedin/i.test(p.platform || '')
|
||||
);
|
||||
const nonLi = pieces.filter(
|
||||
(p) => !/linkedin/i.test(p.platform || '')
|
||||
);
|
||||
pieces.length = 0;
|
||||
pieces.push(...nonLi, ...liFromStrategy);
|
||||
}
|
||||
const plan = emptyMonthPlan(input.ym, input.label);
|
||||
plan.hero.lede = `Estrategia IA · ${monthLabel(input.ym)} · engagement 2026 + ADN ${input.label}.`;
|
||||
const liN = pieces.filter((p) => /linkedin/i.test(p.platform || '')).length;
|
||||
plan.hero.lede = `Estrategia IA · ${monthLabel(input.ym)} · engagement 2026 + ADN ${input.label}${liN ? ' + LinkedIn' : ''}.`;
|
||||
plan.hero.chips = [
|
||||
`${pieces.length} piezas`,
|
||||
`${pieces.filter((p) => /reel/i.test(p.format || '')).length} Reels`,
|
||||
liN ? `${liN} LinkedIn` : '',
|
||||
'IA strategist',
|
||||
];
|
||||
].filter(Boolean);
|
||||
plan.pieces = pieces.sort((a, b) => a.date.localeCompare(b.date));
|
||||
return {
|
||||
plan,
|
||||
rationale: parsed.rationale || fallback.rationale,
|
||||
rationale: [parsed.rationale, fallback.rationale]
|
||||
.filter(Boolean)
|
||||
.join('\n\n'),
|
||||
source: 'anthropic',
|
||||
linkedinMeta: fallback.linkedinMeta,
|
||||
};
|
||||
} catch {
|
||||
return fallback;
|
||||
@@ -205,36 +263,65 @@ export async function generateMonthPreview(
|
||||
(p) => p.title && !isJunkPreviewTitle(p.title)
|
||||
),
|
||||
}))
|
||||
.filter((p) => (p.pieces?.length || 0) >= 3);
|
||||
.filter(
|
||||
(p) =>
|
||||
(p.pieces?.length || 0) >= 3 ||
|
||||
(p.pieces || []).some((x) => /linkedin/i.test(x.platform || ''))
|
||||
);
|
||||
|
||||
// Histórico completo (incl. meses cortos) para no re-agendar posts LI ya usados
|
||||
const liHistory = input.history.map((plan) => ({
|
||||
...plan,
|
||||
pieces: (plan.pieces || []).filter(
|
||||
(p) => p.title && !isJunkPreviewTitle(p.title)
|
||||
),
|
||||
}));
|
||||
|
||||
const go = normalizeGenOptions(input.genOptions);
|
||||
const slotOpts: BuildSlotsOpts & {
|
||||
instructions?: string;
|
||||
regenInstructions?: string;
|
||||
linkedinInstructions?: string;
|
||||
linkedinMode?: string;
|
||||
} = {
|
||||
posts: go.posts,
|
||||
stories: go.stories,
|
||||
reels: go.reels,
|
||||
carruseles: go.carruseles,
|
||||
linkedinPosts: go.linkedinPosts,
|
||||
weekdays: go.weekdays,
|
||||
allowSameDayMix: go.allowSameDayMix,
|
||||
instructions: go.instructions,
|
||||
regenInstructions: go.regenInstructions,
|
||||
linkedinInstructions: go.linkedinInstructions,
|
||||
linkedinMode: go.linkedinMode,
|
||||
};
|
||||
|
||||
const strategist = strategyMonthPreview({
|
||||
...input,
|
||||
history: cleanHistory,
|
||||
history: cleanHistory.length ? cleanHistory : liHistory,
|
||||
linkedinHistory: liHistory,
|
||||
brandbook: input.brandbook,
|
||||
publishedPosts: input.publishedPosts || [],
|
||||
avoidTitles: input.avoidTitles,
|
||||
genOptions: slotOpts,
|
||||
linkedinStrategyPosts: input.linkedinStrategyPosts,
|
||||
linkedinStrategyUrl: input.linkedinStrategyUrl,
|
||||
linkedinPrompt: input.linkedinPrompt,
|
||||
});
|
||||
const fallback: GeneratePreviewResult = {
|
||||
plan: strategist.plan,
|
||||
rationale: strategist.rationale,
|
||||
source: 'strategy',
|
||||
linkedinMeta: strategist.linkedinMeta,
|
||||
};
|
||||
|
||||
// Solo LinkedIn (sin IG): no hace falta Claude
|
||||
const igTotal = go.posts + go.stories + go.reels + go.carruseles;
|
||||
if (igTotal <= 0 && go.linkedinPosts > 0) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return anthropicStrategist(
|
||||
{
|
||||
...input,
|
||||
|
||||
@@ -16,6 +16,13 @@ import {
|
||||
type Brandbook,
|
||||
} from '@/lib/brandbook';
|
||||
import { resolveBrandSlug } from '@/lib/tenant-brand-aliases';
|
||||
import {
|
||||
brandbookLinkedInPromptBlock,
|
||||
collectLinkedInUsage,
|
||||
linkedInDatesInMonth,
|
||||
pickUnpublishedStrategyPosts,
|
||||
type LinkedInStrategyPost,
|
||||
} from '@/lib/linkedin-strategy';
|
||||
|
||||
/** Publicación real en redes (pipeline / IG sync) usada como señal creativa. */
|
||||
export type PublishedNetworkPost = {
|
||||
@@ -45,6 +52,9 @@ export type ClientDna = {
|
||||
networkPostsUsed: number;
|
||||
/** Muestras de captiones/títulos publicados (no copiar literal). */
|
||||
networkSamples: string[];
|
||||
/** Bloque de estrategia LinkedIn (URL + posts + brandbook B2B). */
|
||||
linkedinPrompt?: string;
|
||||
linkedinStrategyUrl?: string;
|
||||
};
|
||||
|
||||
export type TrendBeat = {
|
||||
@@ -474,6 +484,8 @@ export function extractClientDna(
|
||||
calendarMonthsUsed,
|
||||
networkPostsUsed,
|
||||
networkSamples: networkSamplesFromPosts(publishedPosts),
|
||||
linkedinPrompt: brandbookLinkedInPromptBlock(brandbook) || undefined,
|
||||
linkedinStrategyUrl: brandbook?.linkedin_strategy_url || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -488,6 +500,7 @@ export type BuildSlotsOpts = {
|
||||
stories?: number;
|
||||
reels?: number;
|
||||
carruseles?: number;
|
||||
linkedinPosts?: number;
|
||||
weekdays?: number[];
|
||||
/** Si true, se pueden apilar story+post / reel+story el mismo día. */
|
||||
allowSameDayMix?: boolean;
|
||||
@@ -903,8 +916,245 @@ export type StrategyPreviewResult = {
|
||||
source: 'strategy';
|
||||
trends: TrendBeat[];
|
||||
dna: ClientDna;
|
||||
linkedinMeta?: LinkedInScheduleMeta;
|
||||
};
|
||||
|
||||
function pieceFromStrategyPost(
|
||||
sp: LinkedInStrategyPost,
|
||||
date: string,
|
||||
time: string,
|
||||
strategyUrl: string
|
||||
): RrssPiece {
|
||||
const title = cleanEditorialTitle(sp.title).slice(0, 160);
|
||||
const body = (sp.body || '').trim();
|
||||
const hook =
|
||||
body.split('\n').map((l) => l.trim()).find((l) => l.length > 12) || title;
|
||||
const ctaMatch = body.match(
|
||||
/(?:¿|CTA|Comenta|Compartir|Reserva|Agenda|Escríbenos|DM)[^\n]{0,120}/i
|
||||
);
|
||||
return {
|
||||
id: newPieceId(date, title || sp.id),
|
||||
date,
|
||||
time,
|
||||
platform: 'linkedin',
|
||||
format: 'Post',
|
||||
pillar: 'b2b',
|
||||
title: title || `LinkedIn · ${sp.id}`,
|
||||
hook: hook.slice(0, 220),
|
||||
body: body || `${title}\n\n${sp.angle || ''}`.trim(),
|
||||
cta: (ctaMatch?.[0] || 'Comenta tu caso · DM abierto').slice(0, 160),
|
||||
status: 'planned',
|
||||
kind: 'post',
|
||||
color: '#7C66B8',
|
||||
objective_metric: 'engagement_b2b',
|
||||
strategy_url: strategyUrl || undefined,
|
||||
strategy_post_id: sp.id,
|
||||
strategy_angle: sp.angle || sp.meta || undefined,
|
||||
meta_line: strategyUrl
|
||||
? `${strategyUrl}${sp.id ? `#${sp.id}` : ''}`
|
||||
: undefined,
|
||||
} as RrssPiece;
|
||||
}
|
||||
|
||||
function buildLinkedInIdeasFromBrand(input: {
|
||||
ym: string;
|
||||
count: number;
|
||||
label: string;
|
||||
brandbook?: Brandbook | null;
|
||||
dna: ClientDna;
|
||||
weekdays?: number[];
|
||||
strategyUrl?: string;
|
||||
previousSamples?: string[];
|
||||
startIndex?: number;
|
||||
}): RrssPiece[] {
|
||||
const count = Math.max(0, Math.round(input.count || 0));
|
||||
if (!count) return [];
|
||||
const dates = linkedInDatesInMonth(
|
||||
input.ym,
|
||||
count,
|
||||
input.weekdays || [2, 4]
|
||||
);
|
||||
const themes =
|
||||
input.brandbook?.linkedin_themes?.length
|
||||
? input.brandbook.linkedin_themes
|
||||
: input.dna.signatureSeries.length
|
||||
? input.dna.signatureSeries
|
||||
: ['Thought leadership', 'Caso de éxito', 'Opinión sector'];
|
||||
const formats =
|
||||
input.brandbook?.linkedin_formats?.length
|
||||
? input.brandbook.linkedin_formats
|
||||
: ['Post texto', 'Carrusel', 'Documento'];
|
||||
const cta =
|
||||
input.brandbook?.linkedin_cta?.[0] || '¿Te suena? Comenta o mándame DM.';
|
||||
const base = input.startIndex || 0;
|
||||
const out: RrssPiece[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const theme = themes[(base + i) % themes.length] || 'Thought leadership';
|
||||
const fmtHint = formats[(base + i) % formats.length] || 'Post texto';
|
||||
const sample =
|
||||
input.previousSamples?.[(base + i) % Math.max(1, input.previousSamples.length || 1)];
|
||||
const title = `LinkedIn · ${theme}`.slice(0, 160);
|
||||
const date = dates[i] || dates[0];
|
||||
const body = [
|
||||
`Una verdad incómoda sobre ${theme.toLowerCase()}.`,
|
||||
'',
|
||||
`En ${input.label} lo vemos cada semana con equipos que quieren resultados, no ruido.`,
|
||||
sample
|
||||
? `\n(Línea editorial previa — no copies literal: ${sample.slice(0, 160)}…)`
|
||||
: '',
|
||||
'',
|
||||
'3 puntos concretos:',
|
||||
`1) Diagnóstico: qué está frenando ${theme.toLowerCase()}.`,
|
||||
'2) Prueba: un cambio medible en 30 días.',
|
||||
`3) Formato: ${fmtHint}.`,
|
||||
'',
|
||||
cta,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
out.push({
|
||||
id: newPieceId(date, `${title}-${base + i}`),
|
||||
date,
|
||||
time: i % 2 === 0 ? '08:30' : '12:30',
|
||||
platform: 'linkedin',
|
||||
format: /carrusel|documento/i.test(fmtHint) ? 'Carrusel' : 'Post',
|
||||
pillar: 'b2b',
|
||||
title,
|
||||
hook: `Una verdad incómoda sobre ${theme.toLowerCase()}.`,
|
||||
body,
|
||||
cta: cta.slice(0, 160),
|
||||
status: 'planned',
|
||||
kind: 'post',
|
||||
color: '#7C66B8',
|
||||
objective_metric: 'engagement_b2b',
|
||||
strategy_url: input.strategyUrl || undefined,
|
||||
strategy_angle: `${theme} · idea nueva (línea de marca)`,
|
||||
meta_line: input.strategyUrl || undefined,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export type LinkedInScheduleMeta = {
|
||||
mode: 'strategy_backlog' | 'brand_ideas' | 'mixed';
|
||||
strategyTotal: number;
|
||||
alreadyUsed: number;
|
||||
scheduledFromStrategy: number;
|
||||
ideasCreated: number;
|
||||
unpublishedRemaining: number;
|
||||
};
|
||||
|
||||
function buildLinkedInMonthPieces(input: {
|
||||
ym: string;
|
||||
count: number;
|
||||
label: string;
|
||||
strategyUrl: string;
|
||||
strategyPosts: LinkedInStrategyPost[];
|
||||
brandbook?: Brandbook | null;
|
||||
dna: ClientDna;
|
||||
history: RrssMonthPlan[];
|
||||
publishedPosts?: PublishedNetworkPost[];
|
||||
avoidTitles?: string[];
|
||||
weekdays?: number[];
|
||||
}): { pieces: RrssPiece[]; meta: LinkedInScheduleMeta } {
|
||||
const count = Math.max(0, Math.round(input.count || 0));
|
||||
const emptyMeta: LinkedInScheduleMeta = {
|
||||
mode: 'brand_ideas',
|
||||
strategyTotal: 0,
|
||||
alreadyUsed: 0,
|
||||
scheduledFromStrategy: 0,
|
||||
ideasCreated: 0,
|
||||
unpublishedRemaining: 0,
|
||||
};
|
||||
if (!count) return { pieces: [], meta: emptyMeta };
|
||||
|
||||
const usage = collectLinkedInUsage({
|
||||
history: input.history,
|
||||
publishedPosts: input.publishedPosts,
|
||||
avoidTitles: input.avoidTitles,
|
||||
});
|
||||
|
||||
const hasStrategy =
|
||||
Boolean(input.strategyUrl) && (input.strategyPosts?.length || 0) > 0;
|
||||
|
||||
if (hasStrategy) {
|
||||
const pick = pickUnpublishedStrategyPosts(
|
||||
input.strategyPosts,
|
||||
usage,
|
||||
count
|
||||
);
|
||||
const dates = linkedInDatesInMonth(
|
||||
input.ym,
|
||||
count,
|
||||
input.weekdays || [2, 4]
|
||||
);
|
||||
const fromStrategy = pick.toSchedule.map((sp, i) =>
|
||||
pieceFromStrategyPost(
|
||||
sp,
|
||||
dates[i] || dates[0],
|
||||
i % 2 === 0 ? '08:30' : '12:30',
|
||||
input.strategyUrl
|
||||
)
|
||||
);
|
||||
const ideas = buildLinkedInIdeasFromBrand({
|
||||
ym: input.ym,
|
||||
count: pick.ideasNeeded,
|
||||
label: input.label,
|
||||
brandbook: input.brandbook,
|
||||
dna: input.dna,
|
||||
weekdays: input.weekdays,
|
||||
strategyUrl: input.strategyUrl,
|
||||
previousSamples: usage.previousSamples,
|
||||
startIndex: pick.publishedOrPlanned.length + fromStrategy.length,
|
||||
});
|
||||
// Reasignar fechas a ideas tras los de estrategia
|
||||
const ideaDates = linkedInDatesInMonth(
|
||||
input.ym,
|
||||
fromStrategy.length + ideas.length,
|
||||
input.weekdays || [2, 4]
|
||||
);
|
||||
const ideasDated = ideas.map((p, i) => ({
|
||||
...p,
|
||||
date: ideaDates[fromStrategy.length + i] || p.date,
|
||||
}));
|
||||
return {
|
||||
pieces: [...fromStrategy, ...ideasDated],
|
||||
meta: {
|
||||
mode: pick.ideasNeeded > 0 ? 'mixed' : 'strategy_backlog',
|
||||
strategyTotal: input.strategyPosts.length,
|
||||
alreadyUsed: pick.publishedOrPlanned.length,
|
||||
scheduledFromStrategy: fromStrategy.length,
|
||||
ideasCreated: ideasDated.length,
|
||||
unpublishedRemaining: Math.max(
|
||||
0,
|
||||
pick.unpublished.length - fromStrategy.length
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Sin URL / sin posts parseados → solo ideas en línea de marca + histórico LI
|
||||
const ideas = buildLinkedInIdeasFromBrand({
|
||||
ym: input.ym,
|
||||
count,
|
||||
label: input.label,
|
||||
brandbook: input.brandbook,
|
||||
dna: input.dna,
|
||||
weekdays: input.weekdays,
|
||||
strategyUrl: input.strategyUrl || undefined,
|
||||
previousSamples: usage.previousSamples,
|
||||
});
|
||||
return {
|
||||
pieces: ideas,
|
||||
meta: {
|
||||
...emptyMeta,
|
||||
mode: 'brand_ideas',
|
||||
ideasCreated: ideas.length,
|
||||
alreadyUsed: usage.publishedCount + usage.plannedCount,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function strategyMonthPreview(input: {
|
||||
ym: string;
|
||||
slug: string;
|
||||
@@ -912,7 +1162,17 @@ export function strategyMonthPreview(input: {
|
||||
history: RrssMonthPlan[];
|
||||
brandbook?: Brandbook | null;
|
||||
publishedPosts?: PublishedNetworkPost[];
|
||||
genOptions?: BuildSlotsOpts & { instructions?: string };
|
||||
avoidTitles?: string[];
|
||||
/** Histórico completo para detectar posts LI ya usados (puede incluir meses cortos). */
|
||||
linkedinHistory?: RrssMonthPlan[];
|
||||
genOptions?: BuildSlotsOpts & {
|
||||
instructions?: string;
|
||||
linkedinInstructions?: string;
|
||||
};
|
||||
/** Posts parseados de la URL de estrategia LinkedIn. */
|
||||
linkedinStrategyPosts?: LinkedInStrategyPost[];
|
||||
linkedinStrategyUrl?: string;
|
||||
linkedinPrompt?: string;
|
||||
}): StrategyPreviewResult {
|
||||
const dna = extractClientDna(
|
||||
input.slug,
|
||||
@@ -921,6 +1181,11 @@ export function strategyMonthPreview(input: {
|
||||
input.brandbook,
|
||||
input.publishedPosts || []
|
||||
);
|
||||
if (input.linkedinPrompt) dna.linkedinPrompt = input.linkedinPrompt;
|
||||
if (input.linkedinStrategyUrl) {
|
||||
dna.linkedinStrategyUrl = input.linkedinStrategyUrl;
|
||||
}
|
||||
|
||||
const trends = monthTrendBeats(input.ym, dna.brand.vertical);
|
||||
const slots = buildMonthSlots(input.ym, dna, input.genOptions);
|
||||
const pieces: RrssPiece[] = [];
|
||||
@@ -974,15 +1239,42 @@ export function strategyMonthPreview(input: {
|
||||
});
|
||||
});
|
||||
|
||||
const liCount = Math.max(0, Math.round(input.genOptions?.linkedinPosts ?? 0));
|
||||
const liUrl = input.linkedinStrategyUrl || dna.linkedinStrategyUrl || '';
|
||||
let liMeta: LinkedInScheduleMeta | null = null;
|
||||
if (liCount > 0) {
|
||||
const built = buildLinkedInMonthPieces({
|
||||
ym: input.ym,
|
||||
count: liCount,
|
||||
label: input.label,
|
||||
strategyUrl: liUrl,
|
||||
strategyPosts: input.linkedinStrategyPosts || [],
|
||||
brandbook: input.brandbook,
|
||||
dna,
|
||||
history: input.linkedinHistory?.length
|
||||
? input.linkedinHistory
|
||||
: input.history,
|
||||
publishedPosts: input.publishedPosts,
|
||||
avoidTitles: input.avoidTitles,
|
||||
weekdays: input.genOptions?.weekdays,
|
||||
});
|
||||
pieces.push(...built.pieces);
|
||||
liMeta = built.meta;
|
||||
}
|
||||
|
||||
pieces.sort((a, b) => a.date.localeCompare(b.date) || (a.time || '').localeCompare(b.time || ''));
|
||||
|
||||
const plan = emptyMonthPlan(input.ym, input.label);
|
||||
plan.hero.lede = `Estrategia ${monthLabel(input.ym)} · ${dna.brand.vertical}. ADN = Zona de marca + calendarios + pubs reales (sin copiar).`;
|
||||
const liN = pieces.filter((p) => /linkedin/i.test(p.platform || '')).length;
|
||||
plan.hero.lede = `Estrategia ${monthLabel(input.ym)} · ${dna.brand.vertical}. ADN = Zona de marca + calendarios + pubs reales${liN ? ' + LinkedIn B2B' : ''}.`;
|
||||
plan.hero.chips = [
|
||||
`${pieces.length} piezas`,
|
||||
`${pieces.filter((p) => p.format === 'Reel').length} Reels`,
|
||||
`${pieces.filter((p) => p.format === 'Stories').length} Stories`,
|
||||
`${pieces.filter((p) => p.format === 'Post').length} Posts`,
|
||||
`${pieces.filter((p) => p.format === 'Post' && !/linkedin/i.test(p.platform || '')).length} Posts IG`,
|
||||
liN ? `${liN} LinkedIn` : '',
|
||||
dna.networkPostsUsed ? `${dna.networkPostsUsed} pubs hist.` : 'strategist',
|
||||
];
|
||||
].filter(Boolean);
|
||||
plan.pieces = pieces;
|
||||
|
||||
const go = input.genOptions;
|
||||
@@ -990,17 +1282,29 @@ export function strategyMonthPreview(input: {
|
||||
`Estrategia (no copia): DNA de ${dna.brand.label} = series ${dna.signatureSeries.slice(0, 5).join(', ') || 'vertical'}.`,
|
||||
`Fuentes ADN: ${dna.calendarMonthsUsed} mes(es) de calendario editorial · ${dna.networkPostsUsed} publicación(es) reales en redes · ${dna.brandbookPrompt ? 'brandbook' : 'sin brandbook'}.`,
|
||||
go
|
||||
? `Mix pedido: ${go.reels ?? '—'} Reels · ${go.carruseles ?? '—'} Carrusel · ${go.stories ?? '—'} Stories · ${go.posts ?? '—'} Posts · mix mismo día: ${go.allowSameDayMix !== false ? 'sí' : 'no'}.`
|
||||
? `Mix pedido: ${go.reels ?? '—'} Reels · ${go.carruseles ?? '—'} Carrusel · ${go.stories ?? '—'} Stories · ${go.posts ?? '—'} Posts IG · ${go.linkedinPosts ?? 0} LinkedIn · mix mismo día: ${go.allowSameDayMix !== false ? 'sí' : 'no'}.`
|
||||
: `Playbook ES 2026 (IAB): menos posts, más eficiencia · Reels alcance · Carrusel guardados · Stories conversación.`,
|
||||
liMeta
|
||||
? liMeta.mode === 'strategy_backlog'
|
||||
? `LinkedIn: agendados ${liMeta.scheduledFromStrategy} posts pendientes de la estrategia (${liMeta.alreadyUsed} ya publicados/planificados · quedan ${liMeta.unpublishedRemaining} en cola)${liUrl ? ` · ${liUrl}` : ''}.`
|
||||
: liMeta.mode === 'mixed'
|
||||
? `LinkedIn: ${liMeta.scheduledFromStrategy} de la estrategia (aún no publicados) + ${liMeta.ideasCreated} ideas nuevas en línea de marca (${liMeta.alreadyUsed}/${liMeta.strategyTotal} estrategia ya usados).`
|
||||
: `LinkedIn: ${liMeta.ideasCreated} ideas nuevas (sin URL de estrategia o backlog agotado) siguiendo temas/CTAs del brandbook e histórico LI.`
|
||||
: liN
|
||||
? `LinkedIn: ${liN} piezas.`
|
||||
: '',
|
||||
`Tendencias ${monthLabel(input.ym)}: ${trends
|
||||
.slice(0, 4)
|
||||
.map((t) => t.label)
|
||||
.join(' · ')}.`,
|
||||
`Horas: Reels ${PLAYBOOK_2026.hours.reel} · Carrusel ${PLAYBOOK_2026.hours.carrusel} · Stories ${PLAYBOOK_2026.hours.stories} · Post ${PLAYBOOK_2026.hours.post}.`,
|
||||
`Horas: Reels ${PLAYBOOK_2026.hours.reel} · Carrusel ${PLAYBOOK_2026.hours.carrusel} · Stories ${PLAYBOOK_2026.hours.stories} · Post ${PLAYBOOK_2026.hours.post} · LI 08:30/12:30.`,
|
||||
`Conversión (~${pieces.filter((p) => p.pillar === 'conversion').length} piezas) alineada a ${dna.conversionPillars.join('/')}, no spam genérico.`,
|
||||
go?.instructions
|
||||
? `Instrucciones operador: ${go.instructions.slice(0, 400)}`
|
||||
: '',
|
||||
(go as { linkedinInstructions?: string } | undefined)?.linkedinInstructions
|
||||
? `Instrucciones LinkedIn: ${(go as { linkedinInstructions?: string }).linkedinInstructions!.slice(0, 300)}`
|
||||
: '',
|
||||
dna.brandbookPrompt
|
||||
? 'Brandbook activo como constraint creativo.'
|
||||
: dna.voiceNotes,
|
||||
@@ -1008,7 +1312,14 @@ export function strategyMonthPreview(input: {
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
return { plan, rationale, source: 'strategy', trends, dna };
|
||||
return {
|
||||
plan,
|
||||
rationale,
|
||||
source: 'strategy',
|
||||
trends,
|
||||
dna,
|
||||
...(liMeta ? { linkedinMeta: liMeta } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function strategistSystemPrompt(input: {
|
||||
@@ -1020,6 +1331,8 @@ export function strategistSystemPrompt(input: {
|
||||
genOptions?: BuildSlotsOpts & {
|
||||
instructions?: string;
|
||||
regenInstructions?: string;
|
||||
linkedinInstructions?: string;
|
||||
linkedinMode?: string;
|
||||
};
|
||||
}): string {
|
||||
const netBlock =
|
||||
@@ -1036,30 +1349,50 @@ export function strategistSystemPrompt(input: {
|
||||
.join('\n')}\n`
|
||||
: '';
|
||||
const go = input.genOptions;
|
||||
const liN = go?.linkedinPosts ?? 0;
|
||||
const mixBlock = go
|
||||
? `Mix EXACTO pedido: ${go.reels ?? 0} Reels, ${go.carruseles ?? 0} Carrusel, ${go.stories ?? 0} Stories, ${go.posts ?? 0} Posts. Días permitidos (JS weekday 0=Dom): ${(go.weekdays || [1, 2, 3, 4, 5]).join(',')}. Mix mismo día story+post o reel+story: ${go.allowSameDayMix !== false ? 'permitido' : 'prohibido'}.`
|
||||
? `Mix EXACTO pedido: ${go.reels ?? 0} Reels, ${go.carruseles ?? 0} Carrusel, ${go.stories ?? 0} Stories, ${go.posts ?? 0} Posts Instagram/Facebook, ${liN} piezas LinkedIn. Días permitidos (JS weekday 0=Dom): ${(go.weekdays || [1, 2, 3, 4, 5]).join(',')}. Mix mismo día story+post o reel+story: ${go.allowSameDayMix !== false ? 'permitido' : 'prohibido'}.`
|
||||
: `Mix ~28% Reel, 18% Carrusel, 38% Stories, resto Post.`;
|
||||
const instrBlock = [
|
||||
go?.instructions ? `\nINSTRUCCIONES OPERADOR:\n${go.instructions}\n` : '',
|
||||
go?.regenInstructions
|
||||
? `\nINSTRUCCIONES SOLO PARA REGENERADAS:\n${go.regenInstructions}\n`
|
||||
: '',
|
||||
go?.linkedinInstructions
|
||||
? `\nINSTRUCCIONES LINKEDIN:\n${go.linkedinInstructions}\n`
|
||||
: '',
|
||||
].join('');
|
||||
const liBlock = [
|
||||
input.dna.linkedinPrompt || '',
|
||||
liN > 0
|
||||
? `\nLINKEDIN OBLIGATORIO: genera exactamente ${liN} piezas con platform:"linkedin".
|
||||
REGLAS DE PRIORIDAD:
|
||||
1) Si hay URL/catálogo de estrategia: agenda SOLO posts del catálogo que AÚN NO estén publicados ni ya planificados en calendarios previos (respeta strategy_post_id y títulos evitados). Copia el body tal cual si está completo (listo para publicar).
|
||||
2) Si el backlog de la estrategia está vacío o no hay URL: crea ideas NUEVAS siguiendo temas/CTAs/tono del brandbook y el histórico LinkedIn (no repitas ángulos ya usados).
|
||||
3) Nunca re-agendes un post de la estrategia ya marcado como usado.
|
||||
Incluye strategy_url / strategy_post_id / strategy_angle cuando venga del catálogo. Horas LI: 08:30 o 12:30.`
|
||||
: '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
return `Eres Head of Social de 4Ventures. Crea un calendario NUEVO para ${input.label} (${input.ym}).
|
||||
NO copies títulos viejos ni captiones publicadas. Usa como constraint creativo:
|
||||
1) Zona de marca / brandbook
|
||||
2) Histórico de calendarios editoriales (${input.dna.calendarMonthsUsed} meses)
|
||||
3) Publicaciones reales ya salidas en redes (${input.dna.networkPostsUsed} piezas)
|
||||
4) Estrategia LinkedIn B2B (si existe)
|
||||
ADN series: ${input.dna.signatureSeries.join(', ')}.
|
||||
Voz: ${input.dna.brand.tone}. Vertical: ${input.dna.brand.vertical}.
|
||||
Volumen guía: ~${input.dna.avgPerWeek} piezas/semana · Stories ratio hist. ${(input.dna.storiesRatio * 100).toFixed(0)}%.
|
||||
${input.dna.brandbookPrompt ? `\nBRANDBOOK:\n${input.dna.brandbookPrompt}\n` : ''}
|
||||
${liBlock}
|
||||
${netBlock}${avoidBlock}${instrBlock}
|
||||
Playbook 2026 ES: ${PLAYBOOK_2026.principle}
|
||||
${mixBlock}
|
||||
Tendencias del mes:
|
||||
${input.trends.map((t) => `- ${t.label}: ${t.why}`).join('\n')}
|
||||
Devuelve SOLO JSON { rationale, pieces:[{date,time,platform,format,pillar,title,hook,body,cta,objective_metric,slide_copy,reel_script,interactive_story}] }
|
||||
Devuelve SOLO JSON { rationale, pieces:[{date,time,platform,format,pillar,title,hook,body,cta,objective_metric,slide_copy,reel_script,interactive_story,strategy_url,strategy_post_id,strategy_angle}] }
|
||||
Reglas: títulos creativos y específicos (nuevos); rota series del ADN; horas engagement (Reels noche ~19:30, Stories mañana, Carrusel mediodía); KPI por pieza; español; sin "POST 1 · Black Friday" vacío; respeta claims prohibidos del brandbook.
|
||||
Piezas Instagram/Facebook: platform "instagram" (o "facebook" si el brief lo pide). Piezas LinkedIn: platform "linkedin".
|
||||
Para Carrusel: slide_copy = [{n,label,text}] (Slide 1 hook, 2 por qué, …). Para Reel: reel_script = guión desarrollado. Para Stories interactivas: interactive_story = encuesta|quiz|caja|slider y explica el sticker en body (Meta API no publica stickers).`;
|
||||
}
|
||||
|
||||
@@ -95,6 +95,57 @@ export function diffFields(
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parche con valores ANTERIORES completos (sin clip) para poder deshacer.
|
||||
* Solo incluye claves que cambiaron.
|
||||
*/
|
||||
export function buildRevertPatch(
|
||||
before: Record<string, unknown> | null | undefined,
|
||||
after: Record<string, unknown> | null | undefined,
|
||||
opts?: { maxFields?: number; ignoreKeys?: string[] }
|
||||
): Record<string, unknown> {
|
||||
const diffs = diffFields(before, after, {
|
||||
...opts,
|
||||
// Usamos diff solo para saber qué cambió; el patch lleva valor real de before
|
||||
});
|
||||
const a = before || {};
|
||||
const patch: Record<string, unknown> = {};
|
||||
for (const d of diffs) {
|
||||
if (!(d.field in a) && (a[d.field] === undefined || a[d.field] === null)) {
|
||||
// Campo nuevo: al deshacer se elimina / vacía
|
||||
patch[d.field] = null;
|
||||
} else {
|
||||
patch[d.field] = a[d.field] ?? null;
|
||||
}
|
||||
}
|
||||
return patch;
|
||||
}
|
||||
|
||||
export function isHistoryRowRevertable(
|
||||
row: Pick<ChangeHistoryRow, 'action' | 'entity_type' | 'detail'>
|
||||
): boolean {
|
||||
if (row.action === 'undo') return false;
|
||||
const d = row.detail || {};
|
||||
if (d.undone === true) return false;
|
||||
if (d.revertable === false) return false;
|
||||
const patch = d.revert_patch;
|
||||
if (patch && typeof patch === 'object' && !Array.isArray(patch)) {
|
||||
return Object.keys(patch as object).length > 0;
|
||||
}
|
||||
// Legacy: changes con from/to (puede estar clipado — el API validará)
|
||||
const changes = d.changes;
|
||||
return (
|
||||
Array.isArray(changes) &&
|
||||
changes.length > 0 &&
|
||||
changes.some(
|
||||
(c) =>
|
||||
c &&
|
||||
typeof c === 'object' &&
|
||||
typeof (c as { field?: string }).field === 'string'
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function clipJson(v: unknown, n = 240): unknown {
|
||||
try {
|
||||
const s = JSON.stringify(v);
|
||||
|
||||
@@ -794,18 +794,11 @@ export async function recomposeCreativeInCanva(
|
||||
.eq('id', tenantId)
|
||||
.maybeSingle();
|
||||
|
||||
const defaultBpsTemplate = process.env.BPS_CANVA_TEMPLATE_ID || 'EAHRb38v_WY';
|
||||
let canvaTemplates = tenant?.canva_templates || {};
|
||||
if (
|
||||
tenant?.slug === 'bps' &&
|
||||
(!canvaTemplates || Object.keys(canvaTemplates).length === 0)
|
||||
) {
|
||||
canvaTemplates = {
|
||||
'foto-con-franja': defaultBpsTemplate,
|
||||
tipografico: null,
|
||||
default: defaultBpsTemplate,
|
||||
};
|
||||
}
|
||||
const { resolveTenantCanvaTemplates } = await import('@/lib/canva-templates');
|
||||
const canvaTemplates = resolveTenantCanvaTemplates(tenant?.canva_templates, {
|
||||
slug: tenant?.slug,
|
||||
channel: 'ads',
|
||||
});
|
||||
|
||||
const clientInfo = {
|
||||
id: tenant?.id || tenantId,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/** Google Drive (agency marketing@) — OAuth, folder match, list/upload. */
|
||||
|
||||
import { serviceHeaders, supabaseUrl } from '@/lib/agency';
|
||||
import { scoreNameToTenant } from '@/lib/match';
|
||||
|
||||
export const DRIVE_PROVIDER = 'google_drive';
|
||||
|
||||
@@ -258,44 +259,18 @@ export function scoreFolderToTenant(
|
||||
tenantName: string,
|
||||
tenantSlug: string
|
||||
): number {
|
||||
// Seasonal / campaign subfolders are never a client root
|
||||
if (/navidad|black\s*friday|campa[nñ]a|assets?\b/i.test(folderName)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Sibling-brand aware scorer (TYD↔TSP, ECP↔DTATF)
|
||||
// «27-Agua Miia» / «11-Blue Parrot School» → strip numeric prefix
|
||||
const stripped = folderName.replace(/^\d+[\.\-\s_]+/, '').trim();
|
||||
const candidates = [folderName, stripped];
|
||||
const aliasMap: Record<string, string[]> = {
|
||||
bps: ['blue parrot', 'blue parrot school', 'blueparrotschool'],
|
||||
'wine advisors': ['wine advisor', 'wineadvisor'],
|
||||
'de tu a tu formacion': ['detuatu', 'de tu a tu'],
|
||||
'clinica dental sonrisas': ['sonrisas', 'clinica sonrisas'],
|
||||
};
|
||||
let best = 0;
|
||||
for (const raw of candidates) {
|
||||
const f = normalizeName(raw);
|
||||
const n = normalizeName(tenantName);
|
||||
const s = normalizeName(tenantSlug.replace(/-/g, ' '));
|
||||
if (!f || (!n && !s)) continue;
|
||||
if (f === n || f === s) best = Math.max(best, 100);
|
||||
else if (n && (f.includes(n) || n.includes(f))) best = Math.max(best, 85);
|
||||
else if (s && (f.includes(s) || s.includes(f))) best = Math.max(best, 80);
|
||||
else {
|
||||
const aliases = aliasMap[n] || aliasMap[s] || [];
|
||||
for (const a of aliases) {
|
||||
if (f.includes(a) || a.includes(f)) best = Math.max(best, 90);
|
||||
}
|
||||
const ft = new Set(f.split(' ').filter((t) => t.length > 2));
|
||||
const nt = new Set(
|
||||
[...n.split(' '), ...s.split(' ')].filter((t) => t.length > 2)
|
||||
);
|
||||
if (ft.size && nt.size) {
|
||||
let hit = 0;
|
||||
for (const t of nt) if (ft.has(t)) hit += 1;
|
||||
const ratio = hit / nt.size;
|
||||
if (ratio >= 0.8) best = Math.max(best, 70);
|
||||
else if (ratio >= 0.5) best = Math.max(best, 55);
|
||||
else best = Math.max(best, Math.round(ratio * 50));
|
||||
}
|
||||
}
|
||||
}
|
||||
return best;
|
||||
return Math.max(
|
||||
scoreNameToTenant(folderName, tenantName, tenantSlug),
|
||||
scoreNameToTenant(stripped, tenantName, tenantSlug)
|
||||
);
|
||||
}
|
||||
|
||||
export async function listDriveFolders(): Promise<DriveFolder[]> {
|
||||
|
||||
497
src/lib/linkedin-strategy.ts
Normal file
497
src/lib/linkedin-strategy.ts
Normal file
@@ -0,0 +1,497 @@
|
||||
/**
|
||||
* LinkedIn editorial strategies hosted on 4ventures.es
|
||||
* (e.g. /bps/estrategia-linkedin-bps.html) — parse publish-ready posts
|
||||
* and build prompt context for calendar generation.
|
||||
*/
|
||||
|
||||
import type { Brandbook } from '@/lib/brandbook';
|
||||
import { fetchStrategyWebpage } from '@/lib/b2b-analyzer';
|
||||
|
||||
export type LinkedInStrategyPost = {
|
||||
id: string;
|
||||
n: number;
|
||||
title: string;
|
||||
meta?: string;
|
||||
angle?: string;
|
||||
body: string;
|
||||
};
|
||||
|
||||
export type LinkedInStrategyContext = {
|
||||
url: string;
|
||||
filename?: string;
|
||||
excerpt: string;
|
||||
posts: LinkedInStrategyPost[];
|
||||
promptBlock: string;
|
||||
};
|
||||
|
||||
const KNOWN_STRATEGY_BY_SLUG: Record<string, string> = {
|
||||
bps: 'https://4ventures.es/bps/estrategia-linkedin-bps.html',
|
||||
'blue-parrot-school':
|
||||
'https://4ventures.es/bps/estrategia-linkedin-bps.html',
|
||||
blueparrot: 'https://4ventures.es/bps/estrategia-linkedin-bps.html',
|
||||
halal: 'https://4ventures.es/halal/estrategia-linkedin-halal.html',
|
||||
'halal-safety':
|
||||
'https://4ventures.es/halal/estrategia-linkedin-halal.html',
|
||||
hsc: 'https://4ventures.es/halal/estrategia-linkedin-halal.html',
|
||||
aguamiia: 'https://4ventures.es/aguamiia/estrategia-linkedin-aguamiia.html',
|
||||
'agua-miia':
|
||||
'https://4ventures.es/agua-miia/rrss/linkedin/',
|
||||
miia: 'https://4ventures.es/aguamiia/estrategia-linkedin-aguamiia.html',
|
||||
};
|
||||
|
||||
function decodeHtml(html: string): string {
|
||||
return html
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/’/g, "'")
|
||||
.replace(/‘/g, "'")
|
||||
.replace(/“/g, '"')
|
||||
.replace(/”/g, '"')
|
||||
.replace(/–/g, '–')
|
||||
.replace(/—/g, '—')
|
||||
.replace(/ /g, ' ');
|
||||
}
|
||||
|
||||
function stripTags(html: string): string {
|
||||
return decodeHtml(
|
||||
html
|
||||
.replace(/<br\s*\/?>/gi, '\n')
|
||||
.replace(/<\/(p|div|li|h[1-6]|tr)>/gi, '\n')
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
)
|
||||
.replace(/[ \t]+/g, ' ')
|
||||
.replace(/\n[ \t]+/g, '\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/** Parse post-card blocks from 4Ventures LinkedIn strategy HTML pages. */
|
||||
export function parseLinkedInStrategyHtml(html: string): LinkedInStrategyPost[] {
|
||||
if (!html || html.length < 200) return [];
|
||||
const cards = html.match(
|
||||
/<div[^>]*class="[^"]*post-card[^"]*"[^>]*>[\s\S]*?(?=<div[^>]*class="[^"]*post-card|$)/gi
|
||||
);
|
||||
if (!cards?.length) {
|
||||
// Fallback: Post N — Title headings
|
||||
const loose: LinkedInStrategyPost[] = [];
|
||||
const re =
|
||||
/(?:id="(post-\d+)"[\s\S]{0,200})?Post\s+(\d+)\s*[—–\-]\s*([^\n<#]{8,140})/gi;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(html)) && loose.length < 80) {
|
||||
const n = Number(m[2]);
|
||||
const id = m[1] || `post-${n}`;
|
||||
if (loose.some((p) => p.id === id || p.n === n)) continue;
|
||||
loose.push({
|
||||
id,
|
||||
n,
|
||||
title: `Post ${n} — ${m[3].trim()}`,
|
||||
body: '',
|
||||
});
|
||||
}
|
||||
return loose;
|
||||
}
|
||||
|
||||
const out: LinkedInStrategyPost[] = [];
|
||||
for (const card of cards) {
|
||||
const idMatch = card.match(/\bid=["'](post-\d+)["']/i);
|
||||
const headerMatch = card.match(
|
||||
/class=["'][^"']*post-header[^"']*["'][^>]*>([\s\S]*?)(?:<\/div>|<div)/i
|
||||
);
|
||||
const metaMatch = card.match(
|
||||
/class=["'][^"']*post-meta[^"']*["'][^>]*>([\s\S]*?)(?:<\/div>|<div)/i
|
||||
);
|
||||
const stratMatch = card.match(
|
||||
/class=["'][^"']*post-strategy[^"']*["'][^>]*>([\s\S]*?)(?:<\/div>|<div)/i
|
||||
);
|
||||
const bodyMatch = card.match(
|
||||
/class=["'][^"']*post-body[^"']*["'][^>]*>([\s\S]*?)(?:<\/div>\s*(?:<div class="post-|<\/section|$))/i
|
||||
);
|
||||
const titleRaw = stripTags(headerMatch?.[1] || '');
|
||||
const body = stripTags(bodyMatch?.[1] || '');
|
||||
if (!titleRaw && !body) continue;
|
||||
const nMatch = titleRaw.match(/Post\s+(\d+)/i) || idMatch?.[1]?.match(/(\d+)/);
|
||||
const n = nMatch ? Number(nMatch[1]) : out.length + 1;
|
||||
const id = idMatch?.[1] || `post-${n}`;
|
||||
if (out.some((p) => p.id === id)) continue;
|
||||
out.push({
|
||||
id,
|
||||
n,
|
||||
title: titleRaw.slice(0, 180) || `Post ${n}`,
|
||||
meta: stripTags(metaMatch?.[1] || '') || undefined,
|
||||
angle: stripTags(stratMatch?.[1] || '') || undefined,
|
||||
body: body.slice(0, 6000),
|
||||
});
|
||||
}
|
||||
return out.sort((a, b) => a.n - b.n);
|
||||
}
|
||||
|
||||
export function resolveLinkedInStrategyUrl(input: {
|
||||
brandbook?: Brandbook | null;
|
||||
slug?: string;
|
||||
overrideUrl?: string;
|
||||
}): string {
|
||||
const override = String(input.overrideUrl || '').trim();
|
||||
if (override.startsWith('http')) return override;
|
||||
const fromBook = String(input.brandbook?.linkedin_strategy_url || '').trim();
|
||||
if (fromBook.startsWith('http')) return fromBook;
|
||||
const slug = String(input.slug || '')
|
||||
.toLowerCase()
|
||||
.trim();
|
||||
if (slug && KNOWN_STRATEGY_BY_SLUG[slug]) return KNOWN_STRATEGY_BY_SLUG[slug];
|
||||
return '';
|
||||
}
|
||||
|
||||
export function brandbookLinkedInPromptBlock(book: Brandbook | null | undefined): string {
|
||||
if (!book) return '';
|
||||
const lines = [
|
||||
book.linkedin_strategy_url &&
|
||||
`URL estrategia LinkedIn: ${book.linkedin_strategy_url}`,
|
||||
book.linkedin_themes?.length &&
|
||||
`Temas LI: ${book.linkedin_themes.join(' · ')}`,
|
||||
book.linkedin_formats?.length &&
|
||||
`Formatos LI: ${book.linkedin_formats.join(' · ')}`,
|
||||
book.thought_leadership?.length &&
|
||||
`Thought leadership: ${book.thought_leadership.join(' · ')}`,
|
||||
book.buyer_roles?.length &&
|
||||
`Buyer roles: ${book.buyer_roles.join(' · ')}`,
|
||||
book.case_study_angles?.length &&
|
||||
`Ángulos caso: ${book.case_study_angles.join(' · ')}`,
|
||||
book.linkedin_cta?.length &&
|
||||
`CTAs LI: ${book.linkedin_cta.join(' · ')}`,
|
||||
book.abm_notes && `ABM / notas B2B: ${book.abm_notes.slice(0, 800)}`,
|
||||
].filter(Boolean);
|
||||
return lines.length ? `LINKEDIN B2B (brandbook):\n${lines.join('\n')}` : '';
|
||||
}
|
||||
|
||||
export function buildLinkedInPromptBlock(ctx: {
|
||||
url: string;
|
||||
posts: LinkedInStrategyPost[];
|
||||
excerpt: string;
|
||||
brandbook?: Brandbook | null;
|
||||
mode?: 'from_strategy' | 'ideas' | 'mixed';
|
||||
}): string {
|
||||
const mode = ctx.mode || (ctx.posts.length ? 'from_strategy' : 'ideas');
|
||||
const bb = brandbookLinkedInPromptBlock(ctx.brandbook);
|
||||
const catalog = ctx.posts
|
||||
.slice(0, 24)
|
||||
.map((p) => {
|
||||
const bodyPreview = (p.body || '').slice(0, 420).replace(/\s+/g, ' ');
|
||||
return `- [${p.id}] ${p.title}${p.angle ? ` | Ángulo: ${p.angle.slice(0, 160)}` : ''}${
|
||||
bodyPreview ? `\n Copy: ${bodyPreview}` : ''
|
||||
}`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
const rules =
|
||||
mode === 'from_strategy'
|
||||
? `MODO LINKEDIN = from_strategy: prioriza posts YA escritos en la estrategia (copie listo para publicar). Adapta fecha/hora del mes; no inventes otro copy si el post de la estrategia tiene body completo. platform debe ser "linkedin". format: "Post" o "Carrusel" o "Documento".`
|
||||
: mode === 'ideas'
|
||||
? `MODO LINKEDIN = ideas: genera piezas NUEVAS con el tono/pilares de la estrategia y brandbook. Copy listo para pegar en LinkedIn (hook 1-2 líneas + cuerpo con saltos de línea + CTA). platform: "linkedin".`
|
||||
: `MODO LINKEDIN = mixed: usa posts de la estrategia cuando encajen; completa el resto con ideas nuevas del mismo tono. platform: "linkedin".`;
|
||||
|
||||
return [
|
||||
bb,
|
||||
ctx.url ? `Fuente estrategia: ${ctx.url}` : '',
|
||||
rules,
|
||||
catalog
|
||||
? `CATÁLOGO DE POSTS DE LA ESTRATEGIA (referencia / publicar):\n${catalog}`
|
||||
: '',
|
||||
ctx.excerpt
|
||||
? `EXTRACTO ESTRATEGIA (pilares/reglas):\n${ctx.excerpt.slice(0, 6000)}`
|
||||
: '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n\n');
|
||||
}
|
||||
|
||||
/** Load + parse a strategy URL for calendar generation / zoom. */
|
||||
export async function loadLinkedInStrategyContext(input: {
|
||||
url: string;
|
||||
brandbook?: Brandbook | null;
|
||||
mode?: 'from_strategy' | 'ideas' | 'mixed';
|
||||
}): Promise<LinkedInStrategyContext | null> {
|
||||
const url = String(input.url || '').trim();
|
||||
if (!url.startsWith('http')) return null;
|
||||
try {
|
||||
const page = await fetchStrategyWebpage(url);
|
||||
// fetchStrategyWebpage returns stripped text; re-fetch raw HTML for cards
|
||||
let html = '';
|
||||
try {
|
||||
const resp = await fetch(url, {
|
||||
headers: {
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (compatible; 4VenturesStrategyBot/1.0; +https://4ventures.es)',
|
||||
Accept: 'text/html',
|
||||
},
|
||||
signal: AbortSignal.timeout(20000),
|
||||
});
|
||||
if (resp.ok) html = await resp.text();
|
||||
} catch {
|
||||
html = '';
|
||||
}
|
||||
const posts = parseLinkedInStrategyHtml(html);
|
||||
const excerpt = page.text.slice(0, 12000);
|
||||
const promptBlock = buildLinkedInPromptBlock({
|
||||
url,
|
||||
posts,
|
||||
excerpt,
|
||||
brandbook: input.brandbook,
|
||||
mode: input.mode,
|
||||
});
|
||||
return {
|
||||
url,
|
||||
filename: page.filename,
|
||||
excerpt,
|
||||
posts,
|
||||
promptBlock,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Normalize title for fuzzy match strategy ↔ calendar / pipeline. */
|
||||
export function normalizeLiTitleKey(raw: string): string {
|
||||
return String(raw || '')
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/^post\s*\d+\s*[—–\-:·.]\s*/i, '')
|
||||
.replace(/^linkedin\s*[·\-—:]\s*/i, '')
|
||||
.replace(/[^a-z0-9áéíóúñü\s]/gi, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.slice(0, 120);
|
||||
}
|
||||
|
||||
export type LinkedInUsageIndex = {
|
||||
/** strategy_post_id ya usados (post-1, …) */
|
||||
usedIds: Set<string>;
|
||||
/** Títulos normalizados ya publicados o ya en calendario */
|
||||
usedTitleKeys: Set<string>;
|
||||
/** Muestras de piezas LI previas (para generar nuevas en línea de marca) */
|
||||
previousSamples: string[];
|
||||
publishedCount: number;
|
||||
plannedCount: number;
|
||||
};
|
||||
|
||||
type LiUsagePiece = {
|
||||
platform?: string;
|
||||
title?: string;
|
||||
body?: string;
|
||||
hook?: string;
|
||||
strategy_post_id?: string;
|
||||
strategy_url?: string;
|
||||
meta_line?: string;
|
||||
status?: string;
|
||||
};
|
||||
|
||||
type LiUsagePlan = { pieces?: LiUsagePiece[]; month?: string };
|
||||
|
||||
type LiPublished = {
|
||||
title?: string;
|
||||
caption?: string | null;
|
||||
channels?: string[] | null;
|
||||
status?: string | null;
|
||||
};
|
||||
|
||||
function extractPostIdFromMeta(meta?: string): string | null {
|
||||
if (!meta) return null;
|
||||
const m = meta.match(/#(post-\d+)\b/i) || meta.match(/\b(post-\d+)\b/i);
|
||||
return m ? m[1].toLowerCase() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Indexa qué posts de la estrategia ya están publicados o ya agendados
|
||||
* en calendarios editoriales / pipeline.
|
||||
*/
|
||||
export function collectLinkedInUsage(input: {
|
||||
history?: LiUsagePlan[];
|
||||
publishedPosts?: LiPublished[];
|
||||
avoidTitles?: string[];
|
||||
/** Piezas del mes actual a no re-proponer (conservadas). */
|
||||
currentPieces?: LiUsagePiece[];
|
||||
}): LinkedInUsageIndex {
|
||||
const usedIds = new Set<string>();
|
||||
const usedTitleKeys = new Set<string>();
|
||||
const previousSamples: string[] = [];
|
||||
let publishedCount = 0;
|
||||
let plannedCount = 0;
|
||||
|
||||
const markPiece = (p: LiUsagePiece, asPublished: boolean) => {
|
||||
const plat = String(p.platform || '').toLowerCase();
|
||||
const looksLi =
|
||||
plat.includes('linkedin') ||
|
||||
Boolean(p.strategy_post_id) ||
|
||||
/linkedin|estrategia-linkedin/i.test(p.strategy_url || '') ||
|
||||
/linkedin|estrategia-linkedin/i.test(p.meta_line || '');
|
||||
if (!looksLi && !p.strategy_post_id) return;
|
||||
|
||||
if (p.strategy_post_id) usedIds.add(p.strategy_post_id.toLowerCase());
|
||||
const fromMeta = extractPostIdFromMeta(p.meta_line || p.strategy_url);
|
||||
if (fromMeta) usedIds.add(fromMeta);
|
||||
|
||||
for (const t of [p.title, p.hook]) {
|
||||
const k = normalizeLiTitleKey(t || '');
|
||||
if (k.length >= 12) usedTitleKeys.add(k);
|
||||
}
|
||||
if (asPublished) publishedCount += 1;
|
||||
else plannedCount += 1;
|
||||
|
||||
const sample = [p.title, (p.body || '').slice(0, 280)]
|
||||
.filter(Boolean)
|
||||
.join(' — ');
|
||||
if (sample && previousSamples.length < 24) previousSamples.push(sample);
|
||||
};
|
||||
|
||||
for (const plan of input.history || []) {
|
||||
for (const p of plan.pieces || []) markPiece(p, false);
|
||||
}
|
||||
for (const p of input.currentPieces || []) markPiece(p, false);
|
||||
|
||||
for (const row of input.publishedPosts || []) {
|
||||
const ch = (row.channels || []).map((c) => String(c).toLowerCase());
|
||||
const isLi =
|
||||
ch.includes('linkedin') ||
|
||||
/linkedin/i.test(row.title || '') ||
|
||||
/linkedin/i.test(row.caption || '');
|
||||
if (!isLi && ch.length > 0) continue;
|
||||
// Si no hay channels, solo contar si el título encaja con estilo LI post N
|
||||
if (!ch.length && !isLi) continue;
|
||||
const title = row.title || '';
|
||||
const k = normalizeLiTitleKey(title);
|
||||
if (k.length >= 12) usedTitleKeys.add(k);
|
||||
const idFromTitle = title.match(/\bpost[\s#-]*(\d+)\b/i);
|
||||
if (idFromTitle) usedIds.add(`post-${idFromTitle[1]}`);
|
||||
publishedCount += 1;
|
||||
const sample = [title, (row.caption || '').slice(0, 280)]
|
||||
.filter(Boolean)
|
||||
.join(' — ');
|
||||
if (sample && previousSamples.length < 24) previousSamples.push(sample);
|
||||
}
|
||||
|
||||
for (const t of input.avoidTitles || []) {
|
||||
const k = normalizeLiTitleKey(t);
|
||||
if (k.length >= 12) usedTitleKeys.add(k);
|
||||
const id = t.match(/\b(post-\d+)\b/i) || t.match(/^Post\s+(\d+)/i);
|
||||
if (id) {
|
||||
usedIds.add(
|
||||
id[1].toLowerCase().startsWith('post-')
|
||||
? id[1].toLowerCase()
|
||||
: `post-${id[1]}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
usedIds,
|
||||
usedTitleKeys,
|
||||
previousSamples,
|
||||
publishedCount,
|
||||
plannedCount,
|
||||
};
|
||||
}
|
||||
|
||||
function strategyPostIsUsed(
|
||||
sp: LinkedInStrategyPost,
|
||||
usage: LinkedInUsageIndex
|
||||
): boolean {
|
||||
if (usage.usedIds.has(sp.id.toLowerCase())) return true;
|
||||
if (usage.usedIds.has(`post-${sp.n}`)) return true;
|
||||
const tk = normalizeLiTitleKey(sp.title);
|
||||
if (tk.length >= 12 && usage.usedTitleKeys.has(tk)) return true;
|
||||
// Fuzzy: title key contained in any used key or vice-versa
|
||||
if (tk.length >= 18) {
|
||||
for (const u of usage.usedTitleKeys) {
|
||||
if (u.includes(tk) || tk.includes(u)) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export type PickStrategyResult = {
|
||||
unpublished: LinkedInStrategyPost[];
|
||||
publishedOrPlanned: LinkedInStrategyPost[];
|
||||
/** Posts a agendar este mes (prioridad: no usados, en orden de la estrategia). */
|
||||
toSchedule: LinkedInStrategyPost[];
|
||||
/** Cuántos faltan por cubrir con ideas nuevas de marca. */
|
||||
ideasNeeded: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Con URL de estrategia: agenda primero los posts propuestos aún no
|
||||
* publicados/agendados (en orden). Si no bastan, ideasNeeded > 0.
|
||||
*/
|
||||
export function pickUnpublishedStrategyPosts(
|
||||
posts: LinkedInStrategyPost[],
|
||||
usage: LinkedInUsageIndex,
|
||||
count: number
|
||||
): PickStrategyResult {
|
||||
const withBody = posts.filter((p) => (p.body || '').trim().length > 40);
|
||||
const pool = (withBody.length ? withBody : posts).slice().sort((a, b) => a.n - b.n);
|
||||
const unpublished: LinkedInStrategyPost[] = [];
|
||||
const publishedOrPlanned: LinkedInStrategyPost[] = [];
|
||||
for (const sp of pool) {
|
||||
if (strategyPostIsUsed(sp, usage)) publishedOrPlanned.push(sp);
|
||||
else unpublished.push(sp);
|
||||
}
|
||||
const toSchedule = unpublished.slice(0, Math.max(0, count));
|
||||
const ideasNeeded = Math.max(0, count - toSchedule.length);
|
||||
return { unpublished, publishedOrPlanned, toSchedule, ideasNeeded };
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Prefer pickUnpublishedStrategyPosts — rotación ciega re-proponía posts ya usados.
|
||||
*/
|
||||
export function pickStrategyPostsForMonth(
|
||||
posts: LinkedInStrategyPost[],
|
||||
ym: string,
|
||||
count: number
|
||||
): LinkedInStrategyPost[] {
|
||||
if (!posts.length || count <= 0) return [];
|
||||
const withBody = posts.filter((p) => (p.body || '').trim().length > 40);
|
||||
const pool = withBody.length ? withBody : posts;
|
||||
const [y, m] = ym.split('-').map(Number);
|
||||
const offset = ((y || 2026) * 12 + (m || 1)) % Math.max(1, pool.length);
|
||||
const out: LinkedInStrategyPost[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
out.push(pool[(offset + i) % pool.length]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Weekday dates in month for LI posting (default Tue/Thu mornings). */
|
||||
export function linkedInDatesInMonth(
|
||||
ym: string,
|
||||
count: number,
|
||||
weekdays: number[] = [2, 4]
|
||||
): string[] {
|
||||
const [y, m] = ym.split('-').map(Number);
|
||||
if (!y || !m) return [];
|
||||
const days: string[] = [];
|
||||
const last = new Date(Date.UTC(y, m, 0)).getUTCDate();
|
||||
for (let d = 1; d <= last; d++) {
|
||||
const dt = new Date(Date.UTC(y, m - 1, d));
|
||||
if (weekdays.includes(dt.getUTCDay())) {
|
||||
days.push(
|
||||
`${ym}-${String(d).padStart(2, '0')}`
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!days.length) {
|
||||
for (let d = 1; d <= last; d++) {
|
||||
days.push(`${ym}-${String(d).padStart(2, '0')}`);
|
||||
}
|
||||
}
|
||||
if (count <= days.length) {
|
||||
// spread evenly
|
||||
const step = days.length / count;
|
||||
return Array.from({ length: count }, (_, i) => days[Math.min(days.length - 1, Math.floor(i * step))]);
|
||||
}
|
||||
const out: string[] = [];
|
||||
for (let i = 0; i < count; i++) out.push(days[i % days.length]);
|
||||
return out;
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
/** Shared fuzzy match: account/folder name ↔ tenant name/slug. */
|
||||
|
||||
import { resolveBrandSlug } from '@/lib/tenant-brand-aliases';
|
||||
|
||||
export function normalizeName(raw: string): string {
|
||||
return raw
|
||||
.normalize('NFD')
|
||||
@@ -10,6 +12,27 @@ export function normalizeName(raw: string): string {
|
||||
.replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
/** Alias tokens that strongly identify sibling brands (avoid cross-match). */
|
||||
const BRAND_STRONG_ALIASES: Record<string, string[]> = {
|
||||
tyd: ['tyd', 'talento y deporte', 'talentoydeporte', 'beca deportiva'],
|
||||
tsp: ['tsp', 'talento scholar', 'scholar program', 'scholar'],
|
||||
ecp: [
|
||||
'ecp',
|
||||
'escuela de cuidadores',
|
||||
'cuidadores profesionales',
|
||||
'escuelaecp',
|
||||
],
|
||||
dtatf: ['dtatf', 'detuatu', 'de tu a tu', 'detuatuformacion'],
|
||||
};
|
||||
|
||||
/** Signals that should NOT match the sibling brand. */
|
||||
const BRAND_NEGATIVE: Record<string, string[]> = {
|
||||
tyd: ['scholar', 'tsp', 'academico'],
|
||||
tsp: ['deporte', 'tyd', 'deportiva', 'atleta'],
|
||||
ecp: ['detuatu', 'dtatf', 'de tu a tu'],
|
||||
dtatf: ['ecp', 'cuidadores', 'cuidador', 'escuelaecp'],
|
||||
};
|
||||
|
||||
export function scoreNameToTenant(
|
||||
accountName: string,
|
||||
tenantName: string,
|
||||
@@ -17,8 +40,23 @@ export function scoreNameToTenant(
|
||||
): number {
|
||||
const f = normalizeName(accountName);
|
||||
const n = normalizeName(tenantName);
|
||||
const s = normalizeName(tenantSlug.replace(/-/g, ' '));
|
||||
const canon = resolveBrandSlug(tenantSlug);
|
||||
const s = normalizeName(canon.replace(/-/g, ' '));
|
||||
if (!f || (!n && !s)) return 0;
|
||||
|
||||
// Hard negatives between sibling brands (TYD↔TSP, ECP↔DTATF)
|
||||
const negatives = BRAND_NEGATIVE[canon] || [];
|
||||
for (const neg of negatives) {
|
||||
if (f.includes(normalizeName(neg))) return 0;
|
||||
}
|
||||
|
||||
// Strong alias boost
|
||||
const aliases = BRAND_STRONG_ALIASES[canon] || [];
|
||||
for (const a of aliases) {
|
||||
const an = normalizeName(a);
|
||||
if (an && (f === an || f.includes(an) || an.includes(f))) return 95;
|
||||
}
|
||||
|
||||
if (f === n || f === s) return 100;
|
||||
if (n && (f.includes(n) || n.includes(f))) return 85;
|
||||
if (s && (f.includes(s) || s.includes(f))) return 80;
|
||||
@@ -37,6 +75,9 @@ export function scoreNameToTenant(
|
||||
'los',
|
||||
'las',
|
||||
'del',
|
||||
'talento', // too weak alone (TYD vs TSP)
|
||||
'formacion',
|
||||
'escuela',
|
||||
]);
|
||||
const ft = new Set(
|
||||
f.split(' ').filter((t) => t.length > 2 && !WEAK.has(t))
|
||||
|
||||
@@ -615,6 +615,40 @@ async function publishFbPost(
|
||||
return publishFbFeedWithPhotos(pageId, pageToken, message, mediaIds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stub LinkedIn publish — ready for LINKEDIN_ACCESS_TOKEN / org id.
|
||||
* Returns a clear error until credentials exist (does not fake success).
|
||||
*/
|
||||
export async function publishLinkedInPost(item: {
|
||||
title?: string | null;
|
||||
caption?: string | null;
|
||||
}): Promise<{ id: string | null; permalink: string | null; error?: string }> {
|
||||
const token =
|
||||
process.env.LINKEDIN_ACCESS_TOKEN?.trim() ||
|
||||
process.env.LINKEDIN_API_KEY?.trim() ||
|
||||
'';
|
||||
const orgUrn =
|
||||
process.env.LINKEDIN_ORG_URN?.trim() ||
|
||||
process.env.LINKEDIN_AUTHOR_URN?.trim() ||
|
||||
'';
|
||||
if (!token || !orgUrn) {
|
||||
return {
|
||||
id: null,
|
||||
permalink: null,
|
||||
error:
|
||||
'LinkedIn pendiente: configura LINKEDIN_ACCESS_TOKEN y LINKEDIN_ORG_URN (API key / org). El copy ya está listo en el pipeline.',
|
||||
};
|
||||
}
|
||||
// Placeholder for UGC Posts API — wire when credentials land.
|
||||
void item;
|
||||
return {
|
||||
id: null,
|
||||
permalink: null,
|
||||
error:
|
||||
'LinkedIn API conectada parcialmente: falta implementar UGC Posts. Token detectado.',
|
||||
};
|
||||
}
|
||||
|
||||
export async function publishPipelineItem(
|
||||
item: PipelinePublishRow,
|
||||
opts?: { forceIg?: boolean; forceFb?: boolean; skipIg?: boolean; skipFb?: boolean }
|
||||
@@ -627,11 +661,33 @@ export async function publishPipelineItem(
|
||||
...item,
|
||||
caption: scrub.caption || item.title || '',
|
||||
};
|
||||
|
||||
const wantIg = channels.includes('instagram') && !opts?.skipIg;
|
||||
const wantFb = channels.includes('facebook') && !opts?.skipFb;
|
||||
const wantLi = channels.includes('linkedin');
|
||||
const errors: string[] = [];
|
||||
|
||||
// Solo LinkedIn: no exige Meta
|
||||
if (wantLi && !wantIg && !wantFb) {
|
||||
const li = await publishLinkedInPost(cleanItem);
|
||||
return {
|
||||
ok: Boolean(li.id) && !li.error,
|
||||
error: li.error,
|
||||
channels,
|
||||
sanitizedCaption: scrub.caption,
|
||||
captionStripped: scrub.stripped,
|
||||
};
|
||||
}
|
||||
|
||||
const resolved = await resolveTenantMetaTargets(item.tenant_id);
|
||||
if (!resolved.targets) {
|
||||
if (wantLi) {
|
||||
const li = await publishLinkedInPost(cleanItem);
|
||||
errors.push(li.error || 'LinkedIn no publicado');
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
error: resolved.error,
|
||||
error: [resolved.error, ...errors].filter(Boolean).join(' · '),
|
||||
channels,
|
||||
sanitizedCaption: scrub.caption,
|
||||
captionStripped: scrub.stripped,
|
||||
@@ -643,10 +699,6 @@ export async function publishPipelineItem(
|
||||
let fbPostId: string | null = opts?.forceFb ? null : item.meta_fb_post_id || null;
|
||||
let igPermalink: string | null = null;
|
||||
let fbPermalink: string | null = null;
|
||||
const errors: string[] = [];
|
||||
|
||||
const wantIg = channels.includes('instagram') && !opts?.skipIg;
|
||||
const wantFb = channels.includes('facebook') && !opts?.skipFb;
|
||||
|
||||
if (wantIg) {
|
||||
if (!igUserId) {
|
||||
@@ -674,9 +726,20 @@ export async function publishPipelineItem(
|
||||
}
|
||||
}
|
||||
|
||||
let liOk = !wantLi;
|
||||
if (wantLi) {
|
||||
const li = await publishLinkedInPost(cleanItem);
|
||||
if (li.error || !li.id) {
|
||||
errors.push(`LI: ${li.error || 'fallo'}`);
|
||||
liOk = false;
|
||||
} else {
|
||||
liOk = true;
|
||||
}
|
||||
}
|
||||
|
||||
const igOk = !wantIg || Boolean(igMediaId);
|
||||
const fbOk = !wantFb || Boolean(fbPostId);
|
||||
const ok = igOk && fbOk && errors.length === 0;
|
||||
const ok = igOk && fbOk && liOk && errors.length === 0;
|
||||
|
||||
return {
|
||||
ok,
|
||||
|
||||
@@ -1,187 +1,27 @@
|
||||
/**
|
||||
* Mirror creative_pipeline uploads into the client's Google Drive folder:
|
||||
*
|
||||
* {carpeta-cliente}/
|
||||
* Pipeline-creatividades_{nombre-cliente}/
|
||||
* YYYY/
|
||||
* MM/
|
||||
* DD-MM-YYYY_formato_hook[-N].ext
|
||||
* RRSS pipeline → Drive mirror (delegates to creatives-drive).
|
||||
* Folder: Creatividades-RRSS_{slug}/YYYY/MM/{slug}_rrss_{formato}_{fecha}_vN.ext
|
||||
*/
|
||||
import { slugify } from '@/lib/calendar-import';
|
||||
import {
|
||||
applyMatches,
|
||||
driveConfigured,
|
||||
ensureChildFolder,
|
||||
getTenantDriveFolderId,
|
||||
listClientFoldersUnderClientes,
|
||||
listFilesInFolder,
|
||||
matchFoldersToTenants,
|
||||
uploadToDrive,
|
||||
getValidAccessToken,
|
||||
} from '@/lib/google-drive';
|
||||
import { serviceHeaders, supabaseUrl } from '@/lib/agency';
|
||||
import { ASSETS_BUCKET, serviceSupabase } from '@/lib/assets';
|
||||
backfillCreativeItemToDrive,
|
||||
resolveOrLinkTenantDriveFolder,
|
||||
syncCreativeAssetToDrive,
|
||||
type CreativeDriveItem,
|
||||
} from '@/lib/creatives-drive';
|
||||
|
||||
export type PipelineDriveItem = {
|
||||
id: string;
|
||||
title?: string | null;
|
||||
format?: string | null;
|
||||
caption?: string | null;
|
||||
notes?: string | null;
|
||||
scheduled_at?: string | null;
|
||||
created_at?: string | null;
|
||||
export type PipelineDriveItem = CreativeDriveItem & {
|
||||
cover_path?: string | null;
|
||||
slide_paths?: unknown;
|
||||
};
|
||||
|
||||
function pad2(n: number): string {
|
||||
return String(n).padStart(2, '0');
|
||||
}
|
||||
export {
|
||||
resolveOrLinkTenantDriveFolder,
|
||||
pieceLocalDate,
|
||||
creativesDriveRootFolderName as pipelineRootFolderName,
|
||||
buildCreativeDriveFileName as formatDriveFileName,
|
||||
ensureCreativesDriveFolders,
|
||||
} from '@/lib/creatives-drive';
|
||||
|
||||
/** Prefer scheduled local date; fallback created_at / today. */
|
||||
export function pieceLocalDate(item: PipelineDriveItem): Date {
|
||||
const raw = item.scheduled_at || item.created_at;
|
||||
if (raw) {
|
||||
const d = new Date(raw);
|
||||
if (!Number.isNaN(d.getTime())) return d;
|
||||
}
|
||||
return new Date();
|
||||
}
|
||||
|
||||
export function hookLabel(item: PipelineDriveItem): string {
|
||||
const caption = (item.caption || '').trim();
|
||||
if (caption) {
|
||||
const first = caption.split(/\n/)[0].trim();
|
||||
if (first) return first.slice(0, 80);
|
||||
}
|
||||
const title = (item.title || '').trim();
|
||||
if (title) return title.slice(0, 80);
|
||||
return 'creativo';
|
||||
}
|
||||
|
||||
export function formatDriveFileName(opts: {
|
||||
item: PipelineDriveItem;
|
||||
ext: string;
|
||||
slideNumber?: number;
|
||||
}): string {
|
||||
const d = pieceLocalDate(opts.item);
|
||||
const dd = pad2(d.getDate());
|
||||
const mm = pad2(d.getMonth() + 1);
|
||||
const yyyy = d.getFullYear();
|
||||
const format = slugify(opts.item.format || 'post') || 'post';
|
||||
const hook = slugify(hookLabel(opts.item)) || 'creativo';
|
||||
const base = `${dd}-${mm}-${yyyy}_${format}_${hook}`;
|
||||
const ext = (opts.ext || 'jpg').replace(/^\./, '').toLowerCase();
|
||||
if (opts.slideNumber && opts.slideNumber > 0) {
|
||||
return `${base}-${opts.slideNumber}.${ext}`;
|
||||
}
|
||||
return `${base}.${ext}`;
|
||||
}
|
||||
|
||||
export function pipelineRootFolderName(clientLabel: string): string {
|
||||
const name = slugify(clientLabel) || 'cliente';
|
||||
return `Pipeline-creatividades_${name}`;
|
||||
}
|
||||
|
||||
async function replaceDriveFileContent(
|
||||
fileId: string,
|
||||
mimeType: string,
|
||||
buffer: Buffer
|
||||
): Promise<void> {
|
||||
const token = await getValidAccessToken();
|
||||
const r = await fetch(
|
||||
`https://www.googleapis.com/upload/drive/v3/files/${encodeURIComponent(
|
||||
fileId
|
||||
)}?uploadType=media&supportsAllDrives=true`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': mimeType,
|
||||
},
|
||||
body: new Uint8Array(buffer),
|
||||
}
|
||||
);
|
||||
if (!r.ok) {
|
||||
throw new Error(`Drive update: ${(await r.text()).slice(0, 300)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadOrReplace(opts: {
|
||||
parentFolderId: string;
|
||||
fileName: string;
|
||||
mimeType: string;
|
||||
buffer: Buffer;
|
||||
}): Promise<{ id: string; webViewLink?: string; replaced: boolean }> {
|
||||
const existing = await listFilesInFolder(opts.parentFolderId);
|
||||
const hit = existing.find(
|
||||
(f) =>
|
||||
f.name === opts.fileName &&
|
||||
f.mimeType !== 'application/vnd.google-apps.folder'
|
||||
);
|
||||
if (hit) {
|
||||
await replaceDriveFileContent(hit.id, opts.mimeType, opts.buffer);
|
||||
return { id: hit.id, webViewLink: hit.webViewLink, replaced: true };
|
||||
}
|
||||
const created = await uploadToDrive(opts);
|
||||
return { ...created, replaced: false };
|
||||
}
|
||||
|
||||
async function loadTenant(tenantId: string): Promise<{
|
||||
slug: string;
|
||||
name: string;
|
||||
} | null> {
|
||||
const headers = serviceHeaders();
|
||||
if (!headers) return null;
|
||||
const r = await fetch(
|
||||
`${supabaseUrl()}/rest/v1/tenants?id=eq.${encodeURIComponent(
|
||||
tenantId
|
||||
)}&select=slug,name&limit=1`,
|
||||
{ headers, cache: 'no-store' }
|
||||
);
|
||||
if (!r.ok) return null;
|
||||
const rows = (await r.json()) as { slug?: string; name?: string }[];
|
||||
if (!rows[0]) return null;
|
||||
return {
|
||||
slug: rows[0].slug || rows[0].name || 'cliente',
|
||||
name: rows[0].name || rows[0].slug || 'cliente',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve client Drive folder: linked integration, or auto-match under «Clientes».
|
||||
*/
|
||||
export async function resolveOrLinkTenantDriveFolder(
|
||||
tenantId: string,
|
||||
clientLabel: string
|
||||
): Promise<{ folderId: string; folderName?: string; autoLinked?: boolean } | null> {
|
||||
const linked = await getTenantDriveFolderId(tenantId);
|
||||
if (linked?.folderId) return linked;
|
||||
|
||||
const tenant = await loadTenant(tenantId);
|
||||
if (!tenant) return null;
|
||||
|
||||
const { folders } = await listClientFoldersUnderClientes();
|
||||
if (!folders.length) return null;
|
||||
|
||||
const matches = await matchFoldersToTenants(
|
||||
[{ id: tenantId, name: tenant.name || clientLabel, slug: tenant.slug }],
|
||||
folders
|
||||
);
|
||||
const hit = matches.find((m) => m.status === 'matched' && m.folderId);
|
||||
if (!hit?.folderId) return null;
|
||||
|
||||
await applyMatches([hit]);
|
||||
return {
|
||||
folderId: hit.folderId,
|
||||
folderName: hit.folderName || undefined,
|
||||
autoLinked: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure month folder under client's Drive root and upload the creative.
|
||||
*/
|
||||
export async function syncPipelineAssetToDrive(opts: {
|
||||
tenantId: string;
|
||||
clientLabel: string;
|
||||
@@ -191,157 +31,22 @@ export async function syncPipelineAssetToDrive(opts: {
|
||||
ext: string;
|
||||
slot: 'cover' | 'slide';
|
||||
slideIndex: number;
|
||||
}): Promise<{
|
||||
ok: boolean;
|
||||
skipped?: boolean;
|
||||
warning?: string;
|
||||
driveFileId?: string;
|
||||
drivePath?: string;
|
||||
fileName?: string;
|
||||
autoLinked?: boolean;
|
||||
}> {
|
||||
if (!driveConfigured()) {
|
||||
return {
|
||||
ok: false,
|
||||
skipped: true,
|
||||
warning: 'Google Drive no configurado (OAuth agencia)',
|
||||
};
|
||||
}
|
||||
|
||||
let linked: {
|
||||
folderId: string;
|
||||
folderName?: string;
|
||||
autoLinked?: boolean;
|
||||
} | null = null;
|
||||
try {
|
||||
linked = await resolveOrLinkTenantDriveFolder(
|
||||
opts.tenantId,
|
||||
opts.clientLabel
|
||||
);
|
||||
} catch (e) {
|
||||
return {
|
||||
ok: false,
|
||||
warning:
|
||||
e instanceof Error
|
||||
? `Drive auto-link: ${e.message}`
|
||||
: 'Drive auto-link falló',
|
||||
};
|
||||
}
|
||||
|
||||
if (!linked?.folderId) {
|
||||
return {
|
||||
ok: false,
|
||||
skipped: true,
|
||||
warning:
|
||||
'Cliente sin carpeta Drive. Empareja Drive en Agencia (o crea carpeta bajo 03-Clientes).',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const tenant = await loadTenant(opts.tenantId);
|
||||
const rootName = pipelineRootFolderName(
|
||||
tenant?.slug || opts.clientLabel || 'cliente'
|
||||
);
|
||||
const d = pieceLocalDate(opts.item);
|
||||
const year = String(d.getFullYear());
|
||||
const month = pad2(d.getMonth() + 1);
|
||||
|
||||
const pipelineRootId = await ensureChildFolder(linked.folderId, rootName);
|
||||
const yearId = await ensureChildFolder(pipelineRootId, year);
|
||||
const monthId = await ensureChildFolder(yearId, month);
|
||||
|
||||
const isCarousel = (opts.item.format || '').toLowerCase() === 'carrusel';
|
||||
let slideNumber: number | undefined;
|
||||
if (isCarousel) {
|
||||
slideNumber =
|
||||
opts.slot === 'cover' ? 1 : Math.max(2, opts.slideIndex + 2);
|
||||
}
|
||||
|
||||
const fileName = formatDriveFileName({
|
||||
item: opts.item,
|
||||
ext: opts.ext,
|
||||
slideNumber,
|
||||
});
|
||||
|
||||
const up = await uploadOrReplace({
|
||||
parentFolderId: monthId,
|
||||
fileName,
|
||||
mimeType: opts.mimeType,
|
||||
buffer: opts.buffer,
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
driveFileId: up.id,
|
||||
fileName,
|
||||
drivePath: `${linked.folderName || 'cliente'}/${rootName}/${year}/${month}/${fileName}`,
|
||||
autoLinked: linked.autoLinked,
|
||||
};
|
||||
} catch (e) {
|
||||
return {
|
||||
ok: false,
|
||||
warning: e instanceof Error ? e.message : 'Error al subir a Drive',
|
||||
};
|
||||
}
|
||||
}) {
|
||||
return syncCreativeAssetToDrive({
|
||||
...opts,
|
||||
channel: 'rrss',
|
||||
});
|
||||
}
|
||||
|
||||
/** Backfill: push an already-stored cover/slide from Storage → Drive. */
|
||||
export async function backfillPipelineItemToDrive(opts: {
|
||||
tenantId: string;
|
||||
clientLabel: string;
|
||||
item: PipelineDriveItem;
|
||||
}): Promise<{ ok: boolean; results: { slot: string; ok: boolean; detail: string }[] }> {
|
||||
const svc = serviceSupabase();
|
||||
const results: { slot: string; ok: boolean; detail: string }[] = [];
|
||||
if (!svc) {
|
||||
return { ok: false, results: [{ slot: '-', ok: false, detail: 'no service' }] };
|
||||
}
|
||||
|
||||
const jobs: { path: string; slot: 'cover' | 'slide'; index: number }[] = [];
|
||||
if (opts.item.cover_path) {
|
||||
jobs.push({ path: opts.item.cover_path, slot: 'cover', index: 0 });
|
||||
}
|
||||
const slides = Array.isArray(opts.item.slide_paths)
|
||||
? (opts.item.slide_paths as string[])
|
||||
: [];
|
||||
slides.forEach((p, i) => {
|
||||
if (p) jobs.push({ path: p, slot: 'slide', index: i });
|
||||
}) {
|
||||
return backfillCreativeItemToDrive({
|
||||
tenantId: opts.tenantId,
|
||||
clientLabel: opts.clientLabel,
|
||||
channel: 'rrss',
|
||||
item: opts.item,
|
||||
});
|
||||
|
||||
for (const job of jobs) {
|
||||
const dl = await svc.storage.from(ASSETS_BUCKET).download(job.path);
|
||||
if (dl.error || !dl.data) {
|
||||
results.push({
|
||||
slot: job.slot,
|
||||
ok: false,
|
||||
detail: dl.error?.message || 'download fail',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const ab = await dl.data.arrayBuffer();
|
||||
const buf = Buffer.from(ab);
|
||||
const ext =
|
||||
job.path.split('.').pop()?.toLowerCase() ||
|
||||
(dl.data.type.includes('png') ? 'png' : 'jpg');
|
||||
const mime = dl.data.type || 'image/jpeg';
|
||||
const sync = await syncPipelineAssetToDrive({
|
||||
tenantId: opts.tenantId,
|
||||
clientLabel: opts.clientLabel,
|
||||
item: opts.item,
|
||||
buffer: buf,
|
||||
mimeType: mime,
|
||||
ext,
|
||||
slot: job.slot,
|
||||
slideIndex: job.index,
|
||||
});
|
||||
results.push({
|
||||
slot: `${job.slot}${job.slot === 'slide' ? job.index + 1 : ''}`,
|
||||
ok: sync.ok,
|
||||
detail: sync.ok
|
||||
? sync.drivePath || 'ok'
|
||||
: sync.warning || 'fail',
|
||||
});
|
||||
}
|
||||
|
||||
return { ok: results.every((r) => r.ok), results };
|
||||
}
|
||||
|
||||
@@ -215,6 +215,28 @@ export async function processDuePipelinePublishes(opts?: {
|
||||
let igPermalink: string | null = null;
|
||||
let fbPermalink: string | null = null;
|
||||
|
||||
// LinkedIn-only: stub hasta API key (no pasa por Meta)
|
||||
if (
|
||||
channels.includes('linkedin') &&
|
||||
!channels.includes('instagram') &&
|
||||
!channels.includes('facebook')
|
||||
) {
|
||||
const li = await publishPipelineItem(item);
|
||||
await patchItem(base, headers, item.id, {
|
||||
publish_status: li.ok ? 'published' : 'failed',
|
||||
publish_error: li.error || null,
|
||||
published_at: li.ok ? new Date().toISOString() : null,
|
||||
publish_attempts: (item.publish_attempts || 0) + 1,
|
||||
});
|
||||
results.push({
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
ok: Boolean(li.ok),
|
||||
detail: li.ok ? 'linkedin' : li.error || 'linkedin_failed',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (channels.includes('instagram') && !igMediaId && channelDue('instagram')) {
|
||||
const ig = await publishPipelineItem(item, { skipFb: true });
|
||||
if (ig.sanitizedCaption && ig.sanitizedCaption !== (item.caption || '')) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/** Agency scheduling helpers for creative_pipeline. */
|
||||
|
||||
export const PUBLISH_CHANNELS = ['instagram', 'facebook'] as const;
|
||||
export const PUBLISH_CHANNELS = ['instagram', 'facebook', 'linkedin'] as const;
|
||||
export type PublishChannel = (typeof PUBLISH_CHANNELS)[number];
|
||||
|
||||
export const PUBLISH_STATUSES = [
|
||||
@@ -22,6 +22,16 @@ export function normalizeChannels(raw: unknown): PublishChannel[] {
|
||||
return out.length ? [...out] : ['instagram', 'facebook'];
|
||||
}
|
||||
|
||||
/** Default channels for a calendar piece platform. */
|
||||
export function channelsForCalendarPlatform(platform?: string | null): PublishChannel[] {
|
||||
const plat = String(platform || '').toLowerCase();
|
||||
if (plat.includes('linkedin')) return ['linkedin'];
|
||||
if (plat.includes('facebook') && !plat.includes('instagram')) {
|
||||
return ['facebook', 'instagram'];
|
||||
}
|
||||
return ['instagram', 'facebook'];
|
||||
}
|
||||
|
||||
/** Parse datetime-local or ISO into timestamptz ISO, or null to clear. */
|
||||
export function parseScheduledAt(raw: unknown): string | null | undefined {
|
||||
if (raw === null) return null;
|
||||
|
||||
@@ -108,6 +108,8 @@ export type RrssGenerationBriefInput = {
|
||||
socialHistory?: unknown;
|
||||
variantsRequested?: number;
|
||||
extraNotes?: string | null;
|
||||
/** Resolved Canva Brand Template IDs for RRSS (nested ads/rrss or flat). */
|
||||
canvaTemplates?: Record<string, string> | null;
|
||||
};
|
||||
|
||||
export function buildRrssGenerationBrief(input: RrssGenerationBriefInput) {
|
||||
@@ -119,6 +121,7 @@ export function buildRrssGenerationBrief(input: RrssGenerationBriefInput) {
|
||||
client_id: input.tenantId,
|
||||
client_slug: input.clientSlug,
|
||||
client_name: input.clientName,
|
||||
canva_templates: input.canvaTemplates || {},
|
||||
reason: 'calendario' as const,
|
||||
source_creative_id: null,
|
||||
source_pipeline_id: input.pipelineId,
|
||||
|
||||
@@ -579,12 +579,41 @@ const TENANT_DOC_ALIASES: Record<string, string[]> = {
|
||||
'delgado-abogados': ['Delgado Abogados', 'Delgado'],
|
||||
'equinsa-parking': ['Equinsa', 'Equinsa Parking'],
|
||||
'consigue-tus-papeles': ['Consigue Tus Papeles', 'CTP'],
|
||||
'talento-y-deporte': ['Talento y Deporte', 'Talento'],
|
||||
tyd: [
|
||||
'TYD',
|
||||
'Talento y Deporte',
|
||||
'Talento Deporte',
|
||||
'Talento y Deporte',
|
||||
],
|
||||
tsp: [
|
||||
'TSP',
|
||||
'Talento Scholar Program',
|
||||
'Talento Scholar',
|
||||
'Scholar Program',
|
||||
'Scholar',
|
||||
],
|
||||
'la-freseria': ['La Fresería', 'Fresería', 'Freseria'],
|
||||
gravitae: ['Gravitae'],
|
||||
menttum: ['Menttum'],
|
||||
'wine-advisors': ['Wine Advisors', 'Wine'],
|
||||
ecp: ['ECP'],
|
||||
ecp: [
|
||||
'ECP',
|
||||
'Escuela de Cuidadores',
|
||||
'Escuela de cuidadores profesionales',
|
||||
'Cuidadores',
|
||||
],
|
||||
dtatf: [
|
||||
'DTATF',
|
||||
'De Tú a Tú Formación',
|
||||
'De Tu A Tu Formación',
|
||||
'Detuatu',
|
||||
'Detuatuformacion',
|
||||
'De Tu A Tu',
|
||||
],
|
||||
// legacy slug keys (docs still matching old names)
|
||||
'talento-y-deporte': ['Talento y Deporte', 'TYD'],
|
||||
'talento-scholar-program': ['Talento Scholar Program', 'TSP'],
|
||||
'de-tu-a-tu-formacion': ['De Tú a Tú Formación', 'DTATF'],
|
||||
mcd: ['MCD'],
|
||||
'fbm-abogados': ['FBM Abogados', 'FBM', 'Herencias'],
|
||||
'clinica-sonrisas': ['Clínica Dental Sonrisas', 'Sonrisas', 'Clinica Sonrisas'],
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { createServerClient } from '@supabase/ssr';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import {
|
||||
VIEW_TENANT_COOKIE,
|
||||
VIEW_TENANT_NAME_COOKIE,
|
||||
VIEW_TENANT_SLUG_COOKIE,
|
||||
clearedViewTenantCookieOpts,
|
||||
isAgencyRole,
|
||||
userRole,
|
||||
} from '@/lib/agency';
|
||||
|
||||
function withSecurityHeaders(res: NextResponse, path?: string) {
|
||||
res.headers.set('X-Frame-Options', 'DENY');
|
||||
@@ -97,24 +105,22 @@ export async function middleware(req: NextRequest) {
|
||||
return withSecurityHeaders(NextResponse.redirect(loginUrl));
|
||||
}
|
||||
|
||||
const role = userRole(user);
|
||||
const agency = isAgencyRole(role);
|
||||
|
||||
if (user && path === '/login') {
|
||||
const role = user?.app_metadata?.role || user?.user_metadata?.role || 'client';
|
||||
const dashboardUrl = req.nextUrl.clone();
|
||||
dashboardUrl.pathname =
|
||||
role === 'admin' || role === 'team'
|
||||
? '/dashboard/agencia'
|
||||
: '/dashboard/kpis';
|
||||
dashboardUrl.pathname = agency ? '/dashboard/agencia' : '/dashboard/kpis';
|
||||
return withSecurityHeaders(NextResponse.redirect(dashboardUrl));
|
||||
}
|
||||
|
||||
const role = user?.app_metadata?.role || user?.user_metadata?.role || 'client';
|
||||
if (user && role === 'client' && path.startsWith('/dashboard/agencia')) {
|
||||
if (user && !agency && path.startsWith('/dashboard/agencia')) {
|
||||
const forbiddenUrl = req.nextUrl.clone();
|
||||
forbiddenUrl.pathname = '/dashboard/kpis';
|
||||
return withSecurityHeaders(NextResponse.redirect(forbiddenUrl));
|
||||
}
|
||||
|
||||
if (user && role === 'client' && path.startsWith('/api/admin/')) {
|
||||
if (user && !agency && path.startsWith('/api/admin/')) {
|
||||
return withSecurityHeaders(
|
||||
NextResponse.json(
|
||||
{
|
||||
@@ -167,6 +173,21 @@ export async function middleware(req: NextRequest) {
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
});
|
||||
|
||||
// Clientes: no deben conservar cookies de "vista cliente" de una sesión agencia.
|
||||
if (user && !agency) {
|
||||
const hasView =
|
||||
req.cookies.get(VIEW_TENANT_COOKIE)?.value ||
|
||||
req.cookies.get(VIEW_TENANT_NAME_COOKIE)?.value;
|
||||
if (hasView) {
|
||||
const opts = clearedViewTenantCookieOpts(
|
||||
process.env.NODE_ENV === 'production'
|
||||
);
|
||||
res.cookies.set(VIEW_TENANT_COOKIE, '', opts);
|
||||
res.cookies.set(VIEW_TENANT_SLUG_COOKIE, '', opts);
|
||||
res.cookies.set(VIEW_TENANT_NAME_COOKIE, '', opts);
|
||||
}
|
||||
}
|
||||
|
||||
return withSecurityHeaders(res);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user