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:
4Ventures
2026-10-16 04:15:51 +02:00
parent 3b57cdce53
commit 4a1abd3318
54 changed files with 6400 additions and 527 deletions

View File

@@ -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 });
}

View File

@@ -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.';
}
}

View File

@@ -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,
},

View 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 || [],
});
}

View File

@@ -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,
});

View 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,
});
}

View File

@@ -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

View File

@@ -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);
}
}
}

View File

@@ -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();

View File

@@ -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,
},
});
}

View File

@@ -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 });
}

View File

@@ -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,
});
}

View File

@@ -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,

View File

@@ -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');
}

View File

@@ -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');
}

View File

@@ -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}
>

View File

@@ -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)}

View File

@@ -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>

View File

@@ -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">

View File

@@ -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>

View File

@@ -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">

View File

@@ -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>

View File

@@ -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">

View File

@@ -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>;

View File

@@ -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',

View File

@@ -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('; ')}`,

View File

@@ -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),
};
}

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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).`;
}

View File

@@ -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);

View File

@@ -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,

View File

@@ -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[]> {

View 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(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#039;/g, "'")
.replace(/&#8217;/g, "'")
.replace(/&#8216;/g, "'")
.replace(/&#8220;/g, '"')
.replace(/&#8221;/g, '"')
.replace(/&#8211;/g, '–')
.replace(/&#8212;/g, '—')
.replace(/&nbsp;/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;
}

View File

@@ -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))

View File

@@ -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,

View File

@@ -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 };
}

View File

@@ -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 || '')) {

View File

@@ -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;

View File

@@ -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,

View File

@@ -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'],

View File

@@ -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);
}