feat: restaurar todas las secciones en Creatividades Ads con sincronización bidireccional en tiempo real con Zona de Marca

This commit is contained in:
Marcos Fargas
2026-10-14 17:33:29 +02:00
parent 9d7d799ec1
commit 6e8e9ee5c8
5 changed files with 1677 additions and 406 deletions

View File

@@ -1,10 +1,12 @@
/** /**
* GET / PUT brandbook for active tenant (documents type=brandbook). * GET / PUT brandbook for active tenant (documents type=brandbook).
* Sincronización bidireccional permanente con tenants.creative_style.
*/ */
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, serviceHeaders, supabaseUrl, userRole } from '@/lib/agency';
import { createClient as createAdminClient } from '@supabase/supabase-js';
import { import {
BRANDBOOK_DOC_TITLE, BRANDBOOK_DOC_TITLE,
BRANDBOOK_DOC_TYPE, BRANDBOOK_DOC_TYPE,
@@ -17,6 +19,12 @@ import {
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
function getAdmin() {
const url = supabaseUrl();
const key = process.env.SUPABASE_SERVICE_ROLE_KEY!;
return createAdminClient(url, key);
}
async function loadDoc(tenantId: string): Promise<{ async function loadDoc(tenantId: string): Promise<{
id: string | null; id: string | null;
book: Brandbook | null; book: Brandbook | null;
@@ -31,7 +39,7 @@ async function loadDoc(tenantId: string): Promise<{
return { id: row.id, book: parseBrandbook(row.content) }; return { id: row.id, book: parseBrandbook(row.content) };
} }
export async function GET() { export async function GET(req: NextRequest) {
const supabase = await createClient(); const supabase = await createClient();
const { const {
data: { user }, data: { user },
@@ -39,22 +47,48 @@ export async function GET() {
if (!user) { if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
} }
const ctx = await resolveActiveTenant(user);
if (!ctx.tenantId || ctx.portfolioWide) { const isAgency = isAgencyRole(userRole(user));
return NextResponse.json( const queryTenantId = req.nextUrl.searchParams.get('tenant_id') || req.nextUrl.searchParams.get('tenantId');
{ error: 'Selecciona un cliente en Contexto.' },
{ status: 400 } let tenantId: string | null = null;
); let label = 'Cliente';
let slug = 'cliente';
if (isAgency && queryTenantId) {
const admin = getAdmin();
const { data: tenant } = await admin
.from('tenants')
.select('id, name, slug')
.eq('id', queryTenantId)
.maybeSingle();
if (tenant) {
tenantId = tenant.id;
label = tenant.name || 'Cliente';
slug = tenant.slug || 'cliente';
}
} }
let slug = 'cliente'; if (!tenantId) {
const t = await restGet<{ slug?: string }[]>( const ctx = await resolveActiveTenant(user);
`/rest/v1/tenants?id=eq.${encodeURIComponent(ctx.tenantId)}&select=slug` if (!ctx.tenantId || ctx.portfolioWide) {
); return NextResponse.json(
slug = t.data?.[0]?.slug || slug; { error: 'Selecciona un cliente en Contexto.' },
{ status: 400 }
);
}
tenantId = ctx.tenantId;
label = ctx.label;
const { id, book } = await loadDoc(ctx.tenantId); const t = await restGet<{ slug?: string }[]>(
const current = book || emptyBrandbook(ctx.label); `/rest/v1/tenants?id=eq.${encodeURIComponent(tenantId)}&select=slug`
);
slug = t.data?.[0]?.slug || slug;
}
const { id, book } = await loadDoc(tenantId);
const current = book || emptyBrandbook(label);
const completeness = brandbookCompleteness(current); const completeness = brandbookCompleteness(current);
return NextResponse.json({ return NextResponse.json({
@@ -62,7 +96,7 @@ export async function GET() {
id, id,
brandbook: current, brandbook: current,
slug, slug,
label: ctx.label, label,
completeness, completeness,
exists: !!book, exists: !!book,
}); });
@@ -79,28 +113,59 @@ export async function PUT(req: NextRequest) {
if (!isAgencyRole(userRole(user))) { if (!isAgencyRole(userRole(user))) {
return NextResponse.json({ error: 'Solo agencia' }, { status: 403 }); return NextResponse.json({ error: 'Solo agencia' }, { status: 403 });
} }
const ctx = await resolveActiveTenant(user);
if (!ctx.tenantId || ctx.portfolioWide) {
return NextResponse.json(
{ error: 'Selecciona un cliente en Contexto.' },
{ status: 400 }
);
}
const body = (await req.json().catch(() => ({}))) as { const body = (await req.json().catch(() => ({}))) as {
brandbook?: unknown; brandbook?: unknown;
seed?: boolean; seed?: boolean;
tenant_id?: string;
tenantId?: string;
}; };
const queryTenantId =
req.nextUrl.searchParams.get('tenant_id') ||
req.nextUrl.searchParams.get('tenantId') ||
body.tenant_id ||
body.tenantId;
let tenantId: string | null = null;
let label = 'Cliente';
let slug = 'cliente'; let slug = 'cliente';
const t = await restGet<{ slug?: string }[]>(
`/rest/v1/tenants?id=eq.${encodeURIComponent(ctx.tenantId)}&select=slug` if (queryTenantId) {
); const admin = getAdmin();
slug = t.data?.[0]?.slug || slug; const { data: tenant } = await admin
.from('tenants')
.select('id, name, slug')
.eq('id', queryTenantId)
.maybeSingle();
if (tenant) {
tenantId = tenant.id;
label = tenant.name || 'Cliente';
slug = tenant.slug || 'cliente';
}
}
if (!tenantId) {
const ctx = await resolveActiveTenant(user);
if (!ctx.tenantId || ctx.portfolioWide) {
return NextResponse.json(
{ error: 'Selecciona un cliente en Contexto.' },
{ status: 400 }
);
}
tenantId = ctx.tenantId;
label = ctx.label;
const t = await restGet<{ slug?: string }[]>(
`/rest/v1/tenants?id=eq.${encodeURIComponent(tenantId)}&select=slug`
);
slug = t.data?.[0]?.slug || slug;
}
let book: Brandbook | null = null; let book: Brandbook | null = null;
if (body.seed) { if (body.seed) {
book = seedBrandbook(slug, ctx.label); book = seedBrandbook(slug, label);
} else { } else {
book = parseBrandbook(body.brandbook); book = parseBrandbook(body.brandbook);
} }
@@ -108,68 +173,118 @@ export async function PUT(req: NextRequest) {
return NextResponse.json({ error: 'brandbook inválido' }, { status: 400 }); return NextResponse.json({ error: 'brandbook inválido' }, { status: 400 });
} }
book.updated_at = new Date().toISOString(); book.updated_at = new Date().toISOString();
book.brand_name = book.brand_name || ctx.label; book.brand_name = book.brand_name || label;
const headers = serviceHeaders(); const admin = getAdmin();
const base = supabaseUrl(); const { id } = await loadDoc(tenantId);
if (!headers || !base) {
return NextResponse.json({ error: 'No service role' }, { status: 500 });
}
const { id } = await loadDoc(ctx.tenantId); let docId = id;
if (id) { if (id) {
const patch = await fetch(`${base}/rest/v1/documents?id=eq.${id}`, { const { error: patchErr } = await admin
method: 'PATCH', .from('documents')
headers: { .update({
...headers,
'Content-Type': 'application/json',
Prefer: 'return=representation',
},
body: JSON.stringify({
content: book, content: book,
title: BRANDBOOK_DOC_TITLE, title: BRANDBOOK_DOC_TITLE,
updated_at: book.updated_at, updated_at: book.updated_at,
}), })
}); .eq('id', id);
if (!patch.ok) {
return NextResponse.json( if (patchErr) {
{ error: await patch.text() }, return NextResponse.json({ error: patchErr.message }, { status: 500 });
{ status: 500 }
);
} }
const rows = (await patch.json()) as { id: string }[]; } else {
return NextResponse.json({ const { data: insRow, error: insErr } = await admin
ok: true, .from('documents')
id: rows[0]?.id || id, .insert({
brandbook: book, tenant_id: tenantId,
completeness: brandbookCompleteness(book), type: BRANDBOOK_DOC_TYPE,
}); title: BRANDBOOK_DOC_TITLE,
status: 'approved',
content: book,
updated_at: book.updated_at,
})
.select('id')
.single();
if (insErr) {
return NextResponse.json({ error: insErr.message }, { status: 500 });
}
docId = insRow.id;
} }
const ins = await fetch(`${base}/rest/v1/documents`, { // Sincronizar simultáneamente con tenants.creative_style para consistencia 100%
method: 'POST', const { data: tenantRow } = await admin
headers: { .from('tenants')
...headers, .select('creative_style, settings')
'Content-Type': 'application/json', .eq('id', tenantId)
Prefer: 'return=representation', .maybeSingle();
},
body: JSON.stringify({ if (tenantRow) {
tenant_id: ctx.tenantId, const existingStyle = (tenantRow.creative_style && typeof tenantRow.creative_style === 'object') ? tenantRow.creative_style : {};
type: BRANDBOOK_DOC_TYPE, const updatedStyle = {
title: BRANDBOOK_DOC_TITLE, ...existingStyle,
status: 'approved', identity: {
content: book, ...(existingStyle.identity || {}),
}), summary: book.elevator_pitch || existingStyle.identity?.summary || '',
}); website: book.website || existingStyle.identity?.website || '',
if (!ins.ok) { b2b_offering: book.b2b_offering || existingStyle.identity?.b2b_offering || '',
return NextResponse.json({ error: await ins.text() }, { status: 500 }); b2c_offering: book.b2c_offering || existingStyle.identity?.b2c_offering || '',
usp_differentiators: book.usp_differentiators || existingStyle.identity?.usp_differentiators || '',
current_priorities: book.current_priorities || existingStyle.identity?.current_priorities || '',
price_and_offer_framing: book.price_and_offer_framing || existingStyle.identity?.price_and_offer_framing || '',
},
voice: {
...(existingStyle.voice || {}),
tone: book.tone || existingStyle.voice?.tone || '',
copy_structure_rules: book.copy_structure_rules || existingStyle.voice?.copy_structure_rules || '',
english_terms_policy: book.english_terms_policy || existingStyle.voice?.english_terms_policy || '',
power_words: book.power_words?.length ? book.power_words : existingStyle.voice?.power_words || [],
allowed_ctas: book.allowed_ctas?.length ? book.allowed_ctas : existingStyle.voice?.allowed_ctas || [],
},
visual: {
...(existingStyle.visual || {}),
surface_rules: book.surface_rules || existingStyle.visual?.surface_rules || '',
image_text_rules: book.image_text_rules || existingStyle.visual?.image_text_rules || '',
typography_headline: book.typography_headline || existingStyle.visual?.typography_headline || '',
typography_body: book.typography_body || existingStyle.visual?.typography_body || '',
},
audiences: {
...(existingStyle.audiences || {}),
b2b_personas: book.b2b_personas?.length ? book.b2b_personas : existingStyle.audiences?.b2b_personas || [],
b2c_personas: book.b2c_personas?.length ? book.b2c_personas : existingStyle.audiences?.b2c_personas || [],
customer_frustrations: book.customer_frustrations || existingStyle.audiences?.customer_frustrations || '',
desired_transformation: book.desired_transformation || existingStyle.audiences?.desired_transformation || '',
},
arguments: {
...(existingStyle.arguments || {}),
objections: book.objections?.length ? book.objections : existingStyle.arguments?.objections || [],
winning_hooks: book.winning_hooks?.length ? book.winning_hooks : existingStyle.arguments?.winning_hooks || [],
preferred_angles: book.preferred_angles?.length ? book.preferred_angles : existingStyle.arguments?.preferred_angles || [],
},
prohibitions: {
...(existingStyle.prohibitions || {}),
emoji_policy: book.emoji_policy || existingStyle.prohibitions?.emoji_policy || '',
testimonials_policy: book.testimonials_policy || existingStyle.prohibitions?.testimonials_policy || '',
competitors_never_mention: book.competitors_never_mention?.length ? book.competitors_never_mention : existingStyle.prohibitions?.competitors_never_mention || [],
},
};
await admin
.from('tenants')
.update({
creative_style: updatedStyle,
settings: {
...(tenantRow.settings || {}),
creative_style: updatedStyle,
},
})
.eq('id', tenantId);
} }
const rows = (await ins.json()) as { id: string }[];
return NextResponse.json({ return NextResponse.json({
ok: true, ok: true,
id: rows[0]?.id, id: docId,
brandbook: book, brandbook: book,
completeness: brandbookCompleteness(book), completeness: brandbookCompleteness(book),
created: true,
}); });
} }

View File

@@ -241,10 +241,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 // Cargar brandbook de documents para compilar skill actualizado y mantener sincronización
const { data: brandDoc } = await admin const { data: brandDoc } = await admin
.from('documents') .from('documents')
.select('content') .select('id, 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,6 +267,60 @@ 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
if (normalizedStyle.identity) {
if (normalizedStyle.identity.summary) brandbook.elevator_pitch = normalizedStyle.identity.summary;
if (normalizedStyle.identity.website) brandbook.website = normalizedStyle.identity.website;
if (normalizedStyle.identity.b2b_offering) brandbook.b2b_offering = normalizedStyle.identity.b2b_offering;
if (normalizedStyle.identity.b2c_offering) brandbook.b2c_offering = normalizedStyle.identity.b2c_offering;
if (normalizedStyle.identity.usp_differentiators) brandbook.usp_differentiators = normalizedStyle.identity.usp_differentiators;
if (normalizedStyle.identity.current_priorities) brandbook.current_priorities = normalizedStyle.identity.current_priorities;
if (normalizedStyle.identity.price_and_offer_framing) brandbook.price_and_offer_framing = normalizedStyle.identity.price_and_offer_framing;
}
if (normalizedStyle.voice) {
if (normalizedStyle.voice.tone) brandbook.tone = normalizedStyle.voice.tone;
if (normalizedStyle.voice.copy_structure_rules) brandbook.copy_structure_rules = normalizedStyle.voice.copy_structure_rules;
if (normalizedStyle.voice.english_terms_policy) brandbook.english_terms_policy = normalizedStyle.voice.english_terms_policy;
if (normalizedStyle.voice.power_words?.length) brandbook.power_words = normalizedStyle.voice.power_words;
if (normalizedStyle.voice.allowed_ctas?.length) brandbook.allowed_ctas = normalizedStyle.voice.allowed_ctas;
}
if (normalizedStyle.visual) {
if (normalizedStyle.visual.surface_rules) brandbook.surface_rules = normalizedStyle.visual.surface_rules;
if (normalizedStyle.visual.image_text_rules) brandbook.image_text_rules = normalizedStyle.visual.image_text_rules;
if (normalizedStyle.visual.typography_headline) brandbook.typography_headline = normalizedStyle.visual.typography_headline;
if (normalizedStyle.visual.typography_body) brandbook.typography_body = normalizedStyle.visual.typography_body;
}
if (normalizedStyle.audiences) {
if (normalizedStyle.audiences.b2b_personas?.length) brandbook.b2b_personas = normalizedStyle.audiences.b2b_personas;
if (normalizedStyle.audiences.b2c_personas?.length) brandbook.b2c_personas = normalizedStyle.audiences.b2c_personas;
if (normalizedStyle.audiences.customer_frustrations) brandbook.customer_frustrations = normalizedStyle.audiences.customer_frustrations;
if (normalizedStyle.audiences.desired_transformation) brandbook.desired_transformation = normalizedStyle.audiences.desired_transformation;
}
if (normalizedStyle.arguments) {
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) {
await admin
.from('documents')
.update({
content: brandbook,
updated_at: brandbook.updated_at,
})
.eq('id', brandDoc.id);
}
const compiledMarkdown = compileClientSkillFromBrandbook( const compiledMarkdown = compileClientSkillFromBrandbook(
brandbook, brandbook,
{ {

View File

@@ -32,5 +32,5 @@ export default async function MarcaPage() {
); );
} }
return <BrandZone tenantLabel={ctx.label} />; return <BrandZone tenantId={ctx.tenantId || ''} tenantLabel={ctx.label} />;
} }

View File

@@ -97,7 +97,15 @@ function KitThumb({ assetFileId, alt }: { assetFileId: string; alt: string }) {
); );
} }
export default function BrandZone({ tenantLabel }: { tenantLabel: string }) { export default function BrandZone({
tenantId,
tenantLabel,
tenantSlug,
}: {
tenantId?: string;
tenantLabel: string;
tenantSlug?: string;
}) {
const [viewMode, setViewMode] = useState<'overview' | 'edit'>('overview'); const [viewMode, setViewMode] = useState<'overview' | 'edit'>('overview');
const [section, setSection] = useState<BrandbookSectionId>('identidad'); const [section, setSection] = useState<BrandbookSectionId>('identidad');
const [book, setBook] = useState<Brandbook>(() => emptyBrandbook(tenantLabel)); const [book, setBook] = useState<Brandbook>(() => emptyBrandbook(tenantLabel));
@@ -120,11 +128,12 @@ export default function BrandZone({ tenantLabel }: { tenantLabel: string }) {
setLoading(true); setLoading(true);
setErr(null); setErr(null);
try { try {
const r = await fetch('/api/brandbook'); const query = tenantId ? `?tenant_id=${encodeURIComponent(tenantId)}` : '';
const r = await fetch(`/api/brandbook${query}`);
const j = await r.json(); const j = await r.json();
if (!r.ok) throw new Error(j.error || 'No se pudo cargar'); if (!r.ok) throw new Error(j.error || 'No se pudo cargar');
setBook(j.brandbook || emptyBrandbook(tenantLabel)); setBook(j.brandbook || emptyBrandbook(tenantLabel));
setSlug(j.slug || ''); setSlug(j.slug || tenantSlug || '');
setScore(j.completeness?.score ?? 0); setScore(j.completeness?.score ?? 0);
setMissing(j.completeness?.missing || []); setMissing(j.completeness?.missing || []);
setExists(!!j.exists); setExists(!!j.exists);
@@ -133,7 +142,7 @@ export default function BrandZone({ tenantLabel }: { tenantLabel: string }) {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [tenantLabel]); }, [tenantId, tenantLabel, tenantSlug]);
useEffect(() => { useEffect(() => {
void load(); void load();
@@ -149,10 +158,11 @@ export default function BrandZone({ tenantLabel }: { tenantLabel: string }) {
setErr(null); setErr(null);
setOkMsg(null); setOkMsg(null);
try { try {
const r = await fetch('/api/brandbook', { const query = tenantId ? `?tenant_id=${encodeURIComponent(tenantId)}` : '';
const r = await fetch(`/api/brandbook${query}`, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ brandbook: book }), body: JSON.stringify({ brandbook: book, tenant_id: tenantId }),
}); });
const j = await r.json(); const j = await r.json();
if (!r.ok) throw new Error(j.error || 'No se pudo guardar'); if (!r.ok) throw new Error(j.error || 'No se pudo guardar');
@@ -160,7 +170,7 @@ export default function BrandZone({ tenantLabel }: { tenantLabel: string }) {
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'); setOkMsg('Brand kit guardado correctamente 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 {
@@ -172,10 +182,11 @@ export default function BrandZone({ tenantLabel }: { tenantLabel: string }) {
setSeeding(true); setSeeding(true);
setErr(null); setErr(null);
try { try {
const r = await fetch('/api/brandbook', { const query = tenantId ? `?tenant_id=${encodeURIComponent(tenantId)}` : '';
const r = await fetch(`/api/brandbook${query}`, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ seed: true }), body: JSON.stringify({ seed: true, tenant_id: tenantId }),
}); });
const j = await r.json(); const j = await r.json();
if (!r.ok) throw new Error(j.error || 'No se pudo sembrar'); if (!r.ok) throw new Error(j.error || 'No se pudo sembrar');

File diff suppressed because it is too large Load Diff