fix: corregir parseBrandbook para deserializar strings de documents, restaurar paneles y sincronización bidireccional total entre Zona de Marca y Creatividades Ads

This commit is contained in:
Marcos Fargas
2026-10-14 17:47:30 +02:00
parent 6e8e9ee5c8
commit 8b101c094c
5 changed files with 162 additions and 91 deletions

View File

@@ -5,7 +5,7 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server'; import { createClient } from '@/lib/supabase/server';
import { resolveActiveTenant, restGet } from '@/lib/tenant-context'; import { resolveActiveTenant, restGet } from '@/lib/tenant-context';
import { isAgencyRole, serviceHeaders, supabaseUrl, userRole } from '@/lib/agency'; import { isAgencyRole, supabaseUrl, userRole } from '@/lib/agency';
import { createClient as createAdminClient } from '@supabase/supabase-js'; import { createClient as createAdminClient } from '@supabase/supabase-js';
import { import {
BRANDBOOK_DOC_TITLE, BRANDBOOK_DOC_TITLE,
@@ -29,12 +29,16 @@ async function loadDoc(tenantId: string): Promise<{
id: string | null; id: string | null;
book: Brandbook | null; book: Brandbook | null;
}> { }> {
const r = await restGet<{ id: string; content: unknown }[]>( const admin = getAdmin();
`/rest/v1/documents?tenant_id=eq.${encodeURIComponent( const { data: row } = await admin
tenantId .from('documents')
)}&type=eq.${BRANDBOOK_DOC_TYPE}&select=id,content&order=updated_at.desc&limit=1` .select('id, content')
); .eq('tenant_id', tenantId)
const row = r.data?.[0]; .eq('type', BRANDBOOK_DOC_TYPE)
.order('updated_at', { ascending: false })
.limit(1)
.maybeSingle();
if (!row) return { id: null, book: null }; if (!row) return { id: null, book: null };
return { id: row.id, book: parseBrandbook(row.content) }; return { id: row.id, book: parseBrandbook(row.content) };
} }
@@ -48,8 +52,11 @@ export async function GET(req: NextRequest) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
} }
const isAgency = isAgencyRole(userRole(user)); const role = userRole(user);
const queryTenantId = req.nextUrl.searchParams.get('tenant_id') || req.nextUrl.searchParams.get('tenantId'); const isAgency = isAgencyRole(role);
const queryTenantId =
req.nextUrl.searchParams.get('tenant_id') ||
req.nextUrl.searchParams.get('tenantId');
let tenantId: string | null = null; let tenantId: string | null = null;
let label = 'Cliente'; let label = 'Cliente';
@@ -88,7 +95,56 @@ export async function GET(req: NextRequest) {
} }
const { id, book } = await loadDoc(tenantId); const { id, book } = await loadDoc(tenantId);
const current = book || emptyBrandbook(label); let current = book || emptyBrandbook(label);
// Sincronizar / fusionar con tenants.creative_style para que absolutamente NADA se pierda
const admin = getAdmin();
const { data: tenantRow } = await admin
.from('tenants')
.select('creative_style')
.eq('id', tenantId)
.maybeSingle();
if (tenantRow?.creative_style && typeof tenantRow.creative_style === 'object') {
const cs = tenantRow.creative_style as any;
const idObj = cs.identity || {};
const audObj = cs.audiences || {};
const argObj = cs.arguments || {};
const proObj = cs.prohibitions || {};
const voiObj = cs.voice || {};
const visObj = cs.visual || {};
current = {
...current,
website: current.website || idObj.website || '',
b2b_offering: current.b2b_offering || idObj.b2b_offering || '',
b2c_offering: current.b2c_offering || idObj.b2c_offering || '',
usp_differentiators: current.usp_differentiators || idObj.usp_differentiators || '',
current_priorities: current.current_priorities || idObj.current_priorities || '',
price_and_offer_framing: current.price_and_offer_framing || idObj.price_and_offer_framing || '',
elevator_pitch: current.elevator_pitch || idObj.summary || '',
tone: current.tone || voiObj.tone || '',
copy_structure_rules: current.copy_structure_rules || voiObj.copy_structure_rules || '',
english_terms_policy: current.english_terms_policy || voiObj.english_terms_policy || '',
power_words: current.power_words?.length ? current.power_words : (voiObj.power_words || []),
allowed_ctas: current.allowed_ctas?.length ? current.allowed_ctas : (voiObj.allowed_ctas || []),
surface_rules: current.surface_rules || visObj.surface_rules || '',
image_text_rules: current.image_text_rules || visObj.image_text_rules || '',
typography_headline: current.typography_headline || visObj.typography_headline || '',
typography_body: current.typography_body || visObj.typography_body || '',
b2b_personas: current.b2b_personas?.length ? current.b2b_personas : (audObj.b2b_personas || []),
b2c_personas: current.b2c_personas?.length ? current.b2c_personas : (audObj.b2c_personas || []),
customer_frustrations: current.customer_frustrations || audObj.customer_frustrations || '',
desired_transformation: current.desired_transformation || audObj.desired_transformation || '',
objections: current.objections?.length ? current.objections : (argObj.objections || []),
winning_hooks: current.winning_hooks?.length ? current.winning_hooks : (argObj.winning_hooks || []),
preferred_angles: current.preferred_angles?.length ? current.preferred_angles : (argObj.preferred_angles || []),
emoji_policy: current.emoji_policy || proObj.emoji_policy || '',
testimonials_policy: current.testimonials_policy || proObj.testimonials_policy || '',
competitors_never_mention: current.competitors_never_mention?.length ? current.competitors_never_mention : (proObj.competitors_never_mention || []),
};
}
const completeness = brandbookCompleteness(current); const completeness = brandbookCompleteness(current);
return NextResponse.json({ return NextResponse.json({

View File

@@ -91,8 +91,36 @@ export async function GET(req: NextRequest) {
brandbook = null; brandbook = null;
} }
} }
if (!brandbook) { if (!brandbook) {
brandbook = seedBrandbook(tenant.slug || 'cliente', tenant.name || 'Cliente'); brandbook = seedBrandbook(tenant.slug || 'cliente', tenant.name || 'Cliente');
} else {
if (brandbook.website) style.identity.website = brandbook.website;
if (brandbook.b2b_offering) style.identity.b2b_offering = brandbook.b2b_offering;
if (brandbook.b2c_offering) style.identity.b2c_offering = brandbook.b2c_offering;
if (brandbook.usp_differentiators) style.identity.usp_differentiators = brandbook.usp_differentiators;
if (brandbook.current_priorities) style.identity.current_priorities = brandbook.current_priorities;
if (brandbook.price_and_offer_framing) style.identity.price_and_offer_framing = brandbook.price_and_offer_framing;
if (brandbook.elevator_pitch) style.identity.summary = brandbook.elevator_pitch;
if (brandbook.tone) style.voice.tone = brandbook.tone;
if (brandbook.copy_structure_rules) style.voice.copy_structure_rules = brandbook.copy_structure_rules;
if (brandbook.english_terms_policy) style.voice.english_terms_policy = brandbook.english_terms_policy;
if (brandbook.power_words?.length) style.voice.power_words = brandbook.power_words;
if (brandbook.allowed_ctas?.length) style.voice.allowed_ctas = brandbook.allowed_ctas;
if (brandbook.surface_rules) style.visual.surface_rules = brandbook.surface_rules;
if (brandbook.image_text_rules) style.visual.image_text_rules = brandbook.image_text_rules;
if (brandbook.typography_headline) style.visual.typography_headline = brandbook.typography_headline;
if (brandbook.typography_body) style.visual.typography_body = brandbook.typography_body;
if (brandbook.b2b_personas?.length) style.audiences.b2b_personas = brandbook.b2b_personas as any;
if (brandbook.b2c_personas?.length) style.audiences.b2c_personas = brandbook.b2c_personas as any;
if (brandbook.customer_frustrations) style.audiences.customer_frustrations = brandbook.customer_frustrations;
if (brandbook.desired_transformation) style.audiences.desired_transformation = brandbook.desired_transformation;
if (brandbook.objections?.length) style.arguments.objections = brandbook.objections as any;
if (brandbook.winning_hooks?.length) style.arguments.winning_hooks = brandbook.winning_hooks;
if (brandbook.preferred_angles?.length) style.arguments.preferred_angles = brandbook.preferred_angles;
if (brandbook.emoji_policy) style.prohibitions.emoji_policy = brandbook.emoji_policy;
if (brandbook.testimonials_policy) style.prohibitions.testimonials_policy = brandbook.testimonials_policy;
if (brandbook.competitors_never_mention?.length) style.prohibitions.competitors_never_mention = brandbook.competitors_never_mention;
} }
const compiledMarkdown = compileClientSkillFromBrandbook( const compiledMarkdown = compileClientSkillFromBrandbook(
@@ -241,10 +269,10 @@ export async function PUT(req: NextRequest) {
return NextResponse.json({ error: updErr.message }, { status: 500 }); return NextResponse.json({ error: updErr.message }, { status: 500 });
} }
// Cargar brandbook de documents para compilar skill actualizado y mantener sincronización // Cargar brandbook de documents para compilar skill actualizado
const { data: brandDoc } = await admin const { data: brandDoc } = await admin
.from('documents') .from('documents')
.select('id, content') .select('content')
.eq('tenant_id', tenantId) .eq('tenant_id', tenantId)
.eq('type', 'brandbook') .eq('type', 'brandbook')
.order('updated_at', { ascending: false }) .order('updated_at', { ascending: false })
@@ -267,62 +295,61 @@ export async function PUT(req: NextRequest) {
brandbook = seedBrandbook(existing.slug || 'cliente', existing.name || 'Cliente'); brandbook = seedBrandbook(existing.slug || 'cliente', existing.name || 'Cliente');
} }
// Sincronizar campos de normalizedStyle hacia brandbook // Sincronizar simultáneamente con documents (brandbook) para que Zona de Marca esté siempre al día
if (normalizedStyle.identity) { const updatedBrandbook: Brandbook = {
if (normalizedStyle.identity.summary) brandbook.elevator_pitch = normalizedStyle.identity.summary; ...brandbook,
if (normalizedStyle.identity.website) brandbook.website = normalizedStyle.identity.website; elevator_pitch: normalizedStyle.identity.summary || brandbook.elevator_pitch,
if (normalizedStyle.identity.b2b_offering) brandbook.b2b_offering = normalizedStyle.identity.b2b_offering; website: normalizedStyle.identity.website || brandbook.website,
if (normalizedStyle.identity.b2c_offering) brandbook.b2c_offering = normalizedStyle.identity.b2c_offering; b2b_offering: normalizedStyle.identity.b2b_offering || brandbook.b2b_offering,
if (normalizedStyle.identity.usp_differentiators) brandbook.usp_differentiators = normalizedStyle.identity.usp_differentiators; b2c_offering: normalizedStyle.identity.b2c_offering || brandbook.b2c_offering,
if (normalizedStyle.identity.current_priorities) brandbook.current_priorities = normalizedStyle.identity.current_priorities; usp_differentiators: normalizedStyle.identity.usp_differentiators || brandbook.usp_differentiators,
if (normalizedStyle.identity.price_and_offer_framing) brandbook.price_and_offer_framing = normalizedStyle.identity.price_and_offer_framing; current_priorities: normalizedStyle.identity.current_priorities || brandbook.current_priorities,
} price_and_offer_framing: normalizedStyle.identity.price_and_offer_framing || brandbook.price_and_offer_framing,
if (normalizedStyle.voice) { tone: normalizedStyle.voice.tone || brandbook.tone,
if (normalizedStyle.voice.tone) brandbook.tone = normalizedStyle.voice.tone; copy_structure_rules: normalizedStyle.voice.copy_structure_rules || brandbook.copy_structure_rules,
if (normalizedStyle.voice.copy_structure_rules) brandbook.copy_structure_rules = normalizedStyle.voice.copy_structure_rules; english_terms_policy: normalizedStyle.voice.english_terms_policy || brandbook.english_terms_policy,
if (normalizedStyle.voice.english_terms_policy) brandbook.english_terms_policy = normalizedStyle.voice.english_terms_policy; power_words: normalizedStyle.voice.power_words?.length ? normalizedStyle.voice.power_words : brandbook.power_words,
if (normalizedStyle.voice.power_words?.length) brandbook.power_words = normalizedStyle.voice.power_words; allowed_ctas: normalizedStyle.voice.allowed_ctas?.length ? normalizedStyle.voice.allowed_ctas : brandbook.allowed_ctas,
if (normalizedStyle.voice.allowed_ctas?.length) brandbook.allowed_ctas = normalizedStyle.voice.allowed_ctas; surface_rules: normalizedStyle.visual.surface_rules || brandbook.surface_rules,
} image_text_rules: normalizedStyle.visual.image_text_rules || brandbook.image_text_rules,
if (normalizedStyle.visual) { typography_headline: normalizedStyle.visual.typography_headline || brandbook.typography_headline,
if (normalizedStyle.visual.surface_rules) brandbook.surface_rules = normalizedStyle.visual.surface_rules; typography_body: normalizedStyle.visual.typography_body || brandbook.typography_body,
if (normalizedStyle.visual.image_text_rules) brandbook.image_text_rules = normalizedStyle.visual.image_text_rules; b2b_personas: normalizedStyle.audiences.b2b_personas?.length ? (normalizedStyle.audiences.b2b_personas as any) : brandbook.b2b_personas,
if (normalizedStyle.visual.typography_headline) brandbook.typography_headline = normalizedStyle.visual.typography_headline; b2c_personas: normalizedStyle.audiences.b2c_personas?.length ? (normalizedStyle.audiences.b2c_personas as any) : brandbook.b2c_personas,
if (normalizedStyle.visual.typography_body) brandbook.typography_body = normalizedStyle.visual.typography_body; customer_frustrations: normalizedStyle.audiences.customer_frustrations || brandbook.customer_frustrations,
} desired_transformation: normalizedStyle.audiences.desired_transformation || brandbook.desired_transformation,
if (normalizedStyle.audiences) { objections: normalizedStyle.arguments.objections?.length ? (normalizedStyle.arguments.objections as any) : brandbook.objections,
if (normalizedStyle.audiences.b2b_personas?.length) brandbook.b2b_personas = normalizedStyle.audiences.b2b_personas; winning_hooks: normalizedStyle.arguments.winning_hooks?.length ? normalizedStyle.arguments.winning_hooks : brandbook.winning_hooks,
if (normalizedStyle.audiences.b2c_personas?.length) brandbook.b2c_personas = normalizedStyle.audiences.b2c_personas; preferred_angles: normalizedStyle.arguments.preferred_angles?.length ? normalizedStyle.arguments.preferred_angles : brandbook.preferred_angles,
if (normalizedStyle.audiences.customer_frustrations) brandbook.customer_frustrations = normalizedStyle.audiences.customer_frustrations; emoji_policy: normalizedStyle.prohibitions.emoji_policy || brandbook.emoji_policy,
if (normalizedStyle.audiences.desired_transformation) brandbook.desired_transformation = normalizedStyle.audiences.desired_transformation; testimonials_policy: normalizedStyle.prohibitions.testimonials_policy || brandbook.testimonials_policy,
} competitors_never_mention: normalizedStyle.prohibitions.competitors_never_mention?.length ? normalizedStyle.prohibitions.competitors_never_mention : brandbook.competitors_never_mention,
if (normalizedStyle.arguments) { updated_at: new Date().toISOString(),
if (normalizedStyle.arguments.objections?.length) brandbook.objections = normalizedStyle.arguments.objections; };
if (normalizedStyle.arguments.winning_hooks?.length) brandbook.winning_hooks = normalizedStyle.arguments.winning_hooks;
if (normalizedStyle.arguments.preferred_angles?.length) brandbook.preferred_angles = normalizedStyle.arguments.preferred_angles;
}
if (normalizedStyle.prohibitions) {
if (normalizedStyle.prohibitions.emoji_policy) brandbook.emoji_policy = normalizedStyle.prohibitions.emoji_policy;
if (normalizedStyle.prohibitions.testimonials_policy) brandbook.testimonials_policy = normalizedStyle.prohibitions.testimonials_policy;
if (normalizedStyle.prohibitions.competitors_never_mention?.length) brandbook.competitors_never_mention = normalizedStyle.prohibitions.competitors_never_mention;
}
if (normalizedStyle.image_ai?.photo_style) {
brandbook.photography_style = normalizedStyle.image_ai.photo_style;
}
brandbook.updated_at = new Date().toISOString();
if (brandDoc?.id) { if (brandDoc) {
await admin await admin
.from('documents') .from('documents')
.update({ .update({
content: brandbook, content: updatedBrandbook,
updated_at: brandbook.updated_at, updated_at: updatedBrandbook.updated_at,
}) })
.eq('id', brandDoc.id); .eq('id', (brandDoc as any).id);
} else {
await admin
.from('documents')
.insert({
tenant_id: tenantId,
type: 'brandbook',
title: 'Brandbook',
status: 'approved',
content: updatedBrandbook,
updated_at: updatedBrandbook.updated_at,
});
} }
const compiledMarkdown = compileClientSkillFromBrandbook( const compiledMarkdown = compileClientSkillFromBrandbook(
brandbook, updatedBrandbook,
{ {
image_ai: normalizedStyle.image_ai, image_ai: normalizedStyle.image_ai,
campaign_rules: normalizedStyle.campaign_rules, campaign_rules: normalizedStyle.campaign_rules,

View File

@@ -170,7 +170,7 @@ export default function BrandZone({
setScore(j.completeness?.score ?? 0); setScore(j.completeness?.score ?? 0);
setMissing(j.completeness?.missing || []); setMissing(j.completeness?.missing || []);
setExists(true); setExists(true);
setOkMsg('Brand kit guardado correctamente y sincronizado'); setOkMsg('Brand kit guardado y sincronizado');
} catch (e) { } catch (e) {
setErr(e instanceof Error ? e.message : 'Error al guardar'); setErr(e instanceof Error ? e.message : 'Error al guardar');
} finally { } finally {

View File

@@ -2,7 +2,6 @@
import React, { useState, useEffect, useCallback, useMemo } from 'react'; import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { import {
ExternalLink,
Building2, Building2,
Users, Users,
MessageSquare, MessageSquare,
@@ -262,30 +261,6 @@ export default function ClientCreativeStylePanel({
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* BANNER DE SINCRONIZACIÓN CON ZONA DE MARCA */}
<div className="rounded-xl border border-brand-200 bg-brand-50/70 p-4 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 shadow-xs">
<div className="flex items-start gap-3">
<div className="rounded-lg bg-brand-600 p-2 text-white shrink-0 mt-0.5">
<Building2 className="w-5 h-5" />
</div>
<div>
<h4 className="text-sm font-bold text-gray-900 flex items-center gap-2">
Estilo Creativo & ADN Centralizado
<span className="rounded-full bg-emerald-100 text-emerald-800 text-[10px] font-semibold px-2 py-0.5">Sincronizado</span>
</h4>
<p className="text-xs text-gray-600 mt-0.5">
Todo lo que configures aquí (Identidad, Audiencias, Voz, Argumentos, Visual y Operativa) se sincroniza automáticamente con la <strong>Zona de Marca</strong> y alimenta a Claude en tiempo real.
</p>
</div>
</div>
<a
href="/dashboard/marca"
className="inline-flex items-center gap-1.5 rounded-lg bg-brand-600 px-3.5 py-2 text-xs font-semibold text-white hover:bg-brand-700 shrink-0 shadow-xs transition-colors"
>
Ir a Zona de Marca
<ExternalLink className="w-3.5 h-3.5" />
</a>
</div>
{/* Barra superior de estado y guardado */} {/* Barra superior de estado y guardado */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between rounded-2xl border border-brand-200 bg-brand-50/50 p-4"> <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between rounded-2xl border border-brand-200 bg-brand-50/50 p-4">
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
@@ -297,7 +272,7 @@ export default function ClientCreativeStylePanel({
Estilo Creativo & Brief Maestro · {clientName} Estilo Creativo & Brief Maestro · {clientName}
</h2> </h2>
<p className="text-xs text-gray-600 mt-0.5"> <p className="text-xs text-gray-600 mt-0.5">
Todo lo que configures aquí alimenta en tiempo real a <strong>Claude (WF3)</strong> para el copy y estrategia, a <strong>Freepik Mystic</strong> para la escena fotográfica y a <strong>Canva (WF5)</strong> para el montaje automático. Todo lo que configures aquí alimenta en tiempo real a <strong>Claude (WF3)</strong> para el copy y estrategia, a <strong>Freepik Mystic</strong> para la escena fotográfica y a <strong>Canva (WF5)</strong> para el montaje automático. Sincronizado con la <a href="/dashboard/marca" className="font-semibold text-brand-700 underline">Zona de Marca</a>.
</p> </p>
</div> </div>
</div> </div>

View File

@@ -983,8 +983,19 @@ export function seedBrandbook(slug: string, label: string): Brandbook {
} }
export function parseBrandbook(raw: unknown): Brandbook | null { export function parseBrandbook(raw: unknown): Brandbook | null {
if (!raw || typeof raw !== 'object') return null; if (!raw) return null;
const o = raw as Record<string, unknown>; let o: any = raw;
if (typeof raw === 'string') {
try {
o = JSON.parse(raw);
} catch {
return null;
}
}
if (!o || typeof o !== 'object') return null;
if (o.content && (typeof o.content === 'object' || typeof o.content === 'string')) {
return parseBrandbook(o.content);
}
const ver = Number(o.version); const ver = Number(o.version);
if ( if (
ver !== 1 && ver !== 1 &&
@@ -994,11 +1005,13 @@ export function parseBrandbook(raw: unknown): Brandbook | null {
!('tone' in o) && !('tone' in o) &&
!('pillars_rrss' in o) && !('pillars_rrss' in o) &&
!('mission' in o) && !('mission' in o) &&
!('kit_assets' in o) !('kit_assets' in o) &&
!('b2b_offering' in o) &&
!('identity' in o)
) { ) {
return null; return null;
} }
const partial = raw as Partial<Brandbook>; const partial = o as Partial<Brandbook>;
const empty = emptyBrandbook(String(partial.brand_name || '')); const empty = emptyBrandbook(String(partial.brand_name || ''));
const colors = migrateColors(partial, empty); const colors = migrateColors(partial, empty);
const fonts_kit = migrateFonts(partial, empty); const fonts_kit = migrateFonts(partial, empty);