From 3cb3ad1db1e7b900c476cb353959f8860952f522 Mon Sep 17 00:00:00 2001 From: 4Ventures <4ventures@MacBook-Air-de-4Ventures.local> Date: Thu, 15 Oct 2026 03:23:23 +0200 Subject: [PATCH] feat: Zona de marca tipo Estilo/Brief + brandbooks desde ads/RRSS UI con nav lateral numerada y panel editable; parse de brandbook JSON string; enrich desde Meta/Google/IG/FB/web/CSS para que app.4ventures.es muestre el kit. Co-authored-by: Cursor --- scripts/data/scraped_brand_signals.json | 930 +++++++++++++++++++ scripts/enrich_brandbooks.ts | 52 ++ src/app/api/brandbook/enrich/route.ts | 107 +++ src/components/BrandOverview.tsx | 173 +++- src/lib/brandbook-ads-insights.ts | 723 +++++++++++++++ src/lib/brandbook-enrich.ts | 1090 +++++++++++++++++++++++ src/lib/brandbook.ts | 343 ++++++- src/lib/canva-templates.ts | 119 +++ src/lib/creatives-drive.ts | 433 +++++++++ src/lib/tenant-brand-aliases.ts | 66 ++ 10 files changed, 3967 insertions(+), 69 deletions(-) create mode 100644 scripts/data/scraped_brand_signals.json create mode 100644 scripts/enrich_brandbooks.ts create mode 100644 src/app/api/brandbook/enrich/route.ts create mode 100644 src/lib/brandbook-ads-insights.ts create mode 100644 src/lib/brandbook-enrich.ts create mode 100644 src/lib/canva-templates.ts create mode 100644 src/lib/creatives-drive.ts create mode 100644 src/lib/tenant-brand-aliases.ts diff --git a/scripts/data/scraped_brand_signals.json b/scripts/data/scraped_brand_signals.json new file mode 100644 index 0000000..03c8cb8 --- /dev/null +++ b/scripts/data/scraped_brand_signals.json @@ -0,0 +1,930 @@ +{ + "4ventures-hub": { + "site": "https://4ventures.es/", + "theme_color": null, + "css_vars": [], + "top_hex": [ + [ + "#e05200", + 6 + ], + [ + "#25d366", + 4 + ] + ], + "fonts": [ + [ + "DM Sans", + 1 + ], + [ + "Cormorant Garamond", + 1 + ], + [ + "Georgia", + 1 + ] + ] + }, + "agua-miia": { + "site": "https://aguamiia.es/", + "theme_color": null, + "css_vars": [ + "#0085f2", + "#2071cf", + "#e8f1fb", + "#0f4f9e", + "#2e9b76", + "#e6f4ee", + "#1f7257", + "#2071cf", + "#e8f1fb", + "#0f4f9e", + "#c2603f", + "#f7ece6", + "#9a4527", + "#2FA36B", + "#2F80ED" + ], + "top_hex": [ + [ + "#2071cf", + 34 + ], + [ + "#16204a", + 27 + ], + [ + "#c2603f", + 26 + ], + [ + "#8f8f8f", + 21 + ], + [ + "#5b54c9", + 19 + ], + [ + "#dde6ed", + 8 + ], + [ + "#e8f1fb", + 8 + ], + [ + "#f7ece6", + 7 + ], + [ + "#003388", + 6 + ], + [ + "#27374d", + 6 + ], + [ + "#e6f4ee", + 6 + ], + [ + "#eef0f6", + 5 + ] + ], + "fonts": [ + [ + "Urbanist", + 22 + ], + [ + "Nunito", + 4 + ], + [ + "var( --e-global-typography-primary-font-family )", + 2 + ], + [ + "sans-serif !important", + 1 + ], + [ + "var( --e-global-typography-accent-font-family )", + 1 + ] + ] + }, + "bps": { + "site": "https://blueparrotschool.com/", + "theme_color": "#0051AA", + "css_vars": [], + "top_hex": [ + [ + "#0051aa", + 13 + ], + [ + "#5b6472", + 10 + ], + [ + "#201e22", + 6 + ], + [ + "#1187de", + 4 + ], + [ + "#dce8f7", + 4 + ], + [ + "#fdc526", + 3 + ], + [ + "#d6e2f0", + 3 + ], + [ + "#c62828", + 3 + ], + [ + "#0059c2", + 3 + ], + [ + "#eef4fb", + 2 + ], + [ + "#ffd65c", + 2 + ], + [ + "#dde5ee", + 2 + ] + ], + "fonts": [ + [ + "var(--font-display)", + 19 + ], + [ + "Figtree", + 1 + ], + [ + "Jost", + 1 + ], + [ + "var(--font-body)", + 1 + ] + ] + }, + "brava-dermoestetica": { + "site": "https://www.bravadermoestetica.com/", + "theme_color": null, + "css_vars": [], + "top_hex": [ + [ + "#7a3f4d", + 69 + ], + [ + "#61323d", + 7 + ], + [ + "#1c1917", + 4 + ], + [ + "#25d366", + 4 + ], + [ + "#fafaf9", + 3 + ], + [ + "#f8f5f2", + 2 + ] + ], + "fonts": [ + [ + "Myriad Pro", + 3 + ], + [ + "Inter", + 1 + ], + [ + "Playfair Display", + 1 + ] + ] + }, + "delgado-abogados": { + "site": "https://abogadosdelgado.es/", + "theme_color": null, + "css_vars": [ + "#7a00df", + "#007cba", + "#006ba1", + "#005a87", + "#000000", + "#abb8c3", + "#ffffff", + "#f78da7", + "#cf2e2e", + "#ff6900", + "#fcb900", + "#7bdcb5", + "#00d084", + "#8ed1fc", + "#0693e3" + ], + "top_hex": [ + [ + "#212121", + 20 + ], + [ + "#1863dc", + 12 + ], + [ + "#f4f4f4", + 7 + ], + [ + "#fffffe", + 5 + ], + [ + "#ebebeb", + 5 + ], + [ + "#444444", + 3 + ], + [ + "#ff9900", + 3 + ], + [ + "#25d366", + 3 + ], + [ + "#003388", + 2 + ], + [ + "#f0f0f0", + 2 + ], + [ + "#1ea0c3", + 2 + ], + [ + "#0757fe", + 2 + ] + ], + "fonts": [ + [ + "var(--wp--preset--font-family--inter)", + 1 + ], + [ + "var(--wp--preset--font-family--funnel-display)", + 1 + ], + [ + "var(--wp--preset--font-family--allura) !important", + 1 + ], + [ + "var(--wp--preset--font-family--archivo) !important", + 1 + ], + [ + "var(--wp--preset--font-family--barlow) !important", + 1 + ] + ] + }, + "dtatf": { + "site": "https://detuatuformacion.es/", + "theme_color": null, + "css_vars": [ + "#000000", + "#abb8c3", + "#ffffff", + "#f78da7", + "#cf2e2e", + "#ff6900", + "#fcb900", + "#7bdcb5", + "#00d084", + "#8ed1fc", + "#0693e3", + "#9b51e0" + ], + "top_hex": [ + [ + "#788793", + 12 + ], + [ + "#253645", + 4 + ], + [ + "#f6a800", + 2 + ], + [ + "#273645", + 2 + ], + [ + "#ffd671", + 2 + ], + [ + "#f2a74b", + 2 + ] + ], + "fonts": [ + [ + "var( --e-global-typography-text-font-family )", + 6 + ], + [ + "Poppins-DeTUaTU", + 2 + ] + ] + }, + "ecp": { + "site": "https://escueladecuidadoresprofesionales.com/", + "theme_color": null, + "css_vars": [ + "#000000", + "#abb8c3", + "#ffffff", + "#f78da7", + "#cf2e2e", + "#ff6900", + "#fcb900", + "#7bdcb5", + "#00d084", + "#8ed1fc", + "#0693e3", + "#9b51e0", + "#25d366", + "#303030" + ], + "top_hex": [ + [ + "#f7a801", + 27 + ], + [ + "#212121", + 25 + ], + [ + "#1863dc", + 22 + ], + [ + "#f7a800", + 20 + ], + [ + "#dcaa18", + 15 + ], + [ + "#2ea3f2", + 14 + ], + [ + "#faa95e", + 10 + ], + [ + "#f4f4f4", + 8 + ], + [ + "#555555", + 7 + ], + [ + "#0056a7", + 5 + ], + [ + "#0b1f40", + 5 + ], + [ + "#ebebeb", + 5 + ] + ], + "fonts": [ + [ + "Raleway", + 18 + ], + [ + "Open Sans", + 12 + ] + ] + }, + "equinsa-parking": { + "site": "https://equinsaparking.com/", + "theme_color": null, + "css_vars": [ + "#fafafa", + "#fbfbfb", + "#e7e7e7", + "#000", + "#046bd2", + "#045cb4", + "#1e293b", + "#334155", + "#F0F5FA", + "#FFFFFF", + "#D1D5DB", + "#111111", + "#111111", + "#046bd2", + "#045cb4" + ], + "top_hex": [ + [ + "#000028", + 4 + ], + [ + "#003388", + 3 + ], + [ + "#046bd2", + 3 + ], + [ + "#fbfbfb", + 2 + ], + [ + "#475569", + 2 + ], + [ + "#111827", + 2 + ], + [ + "#9ca3af", + 2 + ], + [ + "#045cb4", + 2 + ], + [ + "#1e293b", + 2 + ], + [ + "#334155", + 2 + ], + [ + "#f0f5fa", + 2 + ], + [ + "#d1d5db", + 2 + ] + ], + "fonts": [ + [ + "Roboto", + 9 + ] + ] + }, + "fbm-abogados": { + "site": "https://fbmasociados.com/", + "theme_color": null, + "css_vars": [ + "#000000", + "#abb8c3", + "#ffffff", + "#f78da7", + "#cf2e2e", + "#ff6900", + "#fcb900", + "#7bdcb5", + "#00d084", + "#8ed1fc", + "#0693e3", + "#9b51e0" + ], + "top_hex": [ + [ + "#04243c", + 75 + ], + [ + "#003388", + 16 + ], + [ + "#f9fbfb", + 9 + ] + ], + "fonts": [ + [ + "tahoma", + 1 + ] + ] + }, + "halal-safety": { + "site": "https://www.halalsafety.com/", + "theme_color": null, + "css_vars": [ + "#000000", + "#abb8c3", + "#ffffff", + "#f78da7", + "#cf2e2e", + "#ff6900", + "#fcb900", + "#7bdcb5", + "#00d084", + "#8ed1fc", + "#0693e3", + "#9b51e0" + ], + "top_hex": [ + [ + "#003388", + 8 + ], + [ + "#efefef", + 3 + ] + ], + "fonts": [ + [ + "var(--font-display)", + 3 + ], + [ + "Manrope", + 1 + ] + ] + }, + "la-freseria": { + "site": "https://lafreseria.es/", + "theme_color": null, + "css_vars": [ + "#046bd2", + "#045cb4", + "#1e293b", + "#334155", + "#F0F5FA", + "#FFFFFF", + "#D1D5DB", + "#111111", + "#111111", + "#046bd2", + "#045cb4", + "#1e293b", + "#334155", + "#F0F5FA", + "#FFFFFF" + ], + "top_hex": [ + [ + "#003388", + 6 + ], + [ + "#475569", + 4 + ], + [ + "#046bd2", + 4 + ], + [ + "#9ca3af", + 2 + ], + [ + "#045cb4", + 2 + ], + [ + "#1e293b", + 2 + ], + [ + "#334155", + 2 + ], + [ + "#f0f5fa", + 2 + ], + [ + "#d1d5db", + 2 + ], + [ + "#ef2b2d", + 2 + ] + ], + "fonts": [ + [ + "La Fresería", + 9 + ], + [ + "Campaña La Fresería", + 3 + ], + [ + "Campaña La Fresería 4", + 2 + ], + [ + "Campaña La Fresería 3", + 2 + ], + [ + "Campaña La Fresería 2", + 1 + ] + ] + }, + "noberplast": { + "site": "https://noberplast.com/", + "theme_color": null, + "css_vars": [], + "top_hex": [ + [ + "#dde0e4", + 6 + ], + [ + "#bbbbbb", + 4 + ], + [ + "#06a161", + 4 + ], + [ + "#db062c", + 4 + ], + [ + "#0072ab", + 3 + ], + [ + "#e2e2e2", + 2 + ], + [ + "#444444", + 2 + ] + ], + "fonts": [] + }, + "tsp": { + "site": "https://talentoscholarprogram.com/", + "theme_color": null, + "css_vars": [], + "top_hex": [ + [ + "#a4caef", + 10 + ], + [ + "#161b2f", + 7 + ], + [ + "#a40406", + 2 + ], + [ + "#2c3349", + 2 + ] + ], + "fonts": [ + [ + "Poppins", + 20 + ], + [ + "Fraunces", + 4 + ], + [ + "Mulish", + 3 + ] + ] + }, + "tyd": { + "site": "https://talentoydeporte.com/", + "theme_color": null, + "css_vars": [ + "#000000", + "#abb8c3", + "#ffffff", + "#f78da7", + "#cf2e2e", + "#ff6900", + "#fcb900", + "#7bdcb5", + "#00d084", + "#8ed1fc", + "#0693e3", + "#9b51e0" + ], + "top_hex": [ + [ + "#e92428", + 18 + ], + [ + "#004885", + 8 + ], + [ + "#003388", + 7 + ], + [ + "#32373c", + 2 + ], + [ + "#f00101", + 2 + ] + ], + "fonts": [ + [ + "Montserrat", + 3 + ], + [ + "Oswald", + 1 + ] + ] + }, + "virginia-molet": { + "site": "https://gravitaeacademy.com/es/inicio/", + "theme_color": null, + "css_vars": [ + "#000000", + "#abb8c3", + "#ffffff", + "#f78da7", + "#cf2e2e", + "#ff6900", + "#fcb900", + "#7bdcb5", + "#00d084", + "#8ed1fc", + "#0693e3", + "#9b51e0", + "#000000", + "#141214" + ], + "top_hex": [ + [ + "#1863dc", + 49 + ], + [ + "#212121", + 25 + ], + [ + "#fafbfa", + 21 + ], + [ + "#004271", + 19 + ], + [ + "#f4f4f4", + 8 + ], + [ + "#003388", + 7 + ], + [ + "#141214", + 6 + ], + [ + "#0056a7", + 5 + ], + [ + "#ebebeb", + 5 + ], + [ + "#4e4b66", + 4 + ], + [ + "#1e1e1e", + 4 + ], + [ + "#d0d5d2", + 3 + ] + ], + "fonts": [ + [ + "quincy-cf", + 5 + ], + [ + "Inter", + 4 + ], + [ + "Inter'!important", + 1 + ], + [ + "serif!important", + 1 + ] + ] + }, + "wine-advisors": { + "site": "https://www.wineadvisor.mx/", + "theme_color": "#fafafa", + "css_vars": [], + "top_hex": [ + [ + "#cc0100", + 2 + ] + ], + "fonts": [] + } +} \ No newline at end of file diff --git a/scripts/enrich_brandbooks.ts b/scripts/enrich_brandbooks.ts new file mode 100644 index 0000000..a33da31 --- /dev/null +++ b/scripts/enrich_brandbooks.ts @@ -0,0 +1,52 @@ +/** + * Auto-fill Zona de marca for all tenants (or one slug) + ensure Drive folders. + * + * npx tsx scripts/enrich_brandbooks.ts + * npx tsx scripts/enrich_brandbooks.ts bps + * DRY_RUN=1 npx tsx scripts/enrich_brandbooks.ts + * FORCE_SEED=1 npx tsx scripts/enrich_brandbooks.ts + */ +import { config } from 'dotenv'; +import { resolve } from 'path'; +config({ path: resolve(process.cwd(), '.env.local') }); + +import { enrichAllTenantBrandbooks } from '../src/lib/brandbook-enrich'; +import { ensureCreativesDriveFolders } from '../src/lib/creatives-drive'; + +async function main() { + const slugFilter = process.argv[2]?.trim(); + const dryRun = process.env.DRY_RUN === '1'; + const forceSeed = process.env.FORCE_SEED === '1'; + + console.log( + `Enrich brandbooks · dryRun=${dryRun} · forceSeed=${forceSeed} · filter=${slugFilter || '*'}` + ); + + const results = await enrichAllTenantBrandbooks({ + dryRun, + forceSeed, + slugFilter, + }); + + for (const r of results) { + console.log( + `[${r.slug}] ${r.ok ? 'OK' : 'FAIL'} · ${r.created ? 'created' : 'updated'} · ` + + `score ${r.completenessBefore}→${r.completenessAfter} · ` + + `sources=${r.sources.join('+')} · filled=${r.filledFields.slice(0, 12).join(',')}` + + (r.warning ? ` · ${r.warning}` : '') + ); + if (r.ok && !dryRun) { + const drive = await ensureCreativesDriveFolders(r.tenantId, r.slug); + console.log( + ` drive folders: ${drive.ok ? 'ads+rrss ok' : drive.warning || 'skip'}` + ); + } + } + + console.log(`Done · ${results.length} tenants`); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/src/app/api/brandbook/enrich/route.ts b/src/app/api/brandbook/enrich/route.ts new file mode 100644 index 0000000..9f5e85d --- /dev/null +++ b/src/app/api/brandbook/enrich/route.ts @@ -0,0 +1,107 @@ +/** + * POST — auto-fill Zona de marca for one or all tenants from available sources. + * Body: { tenant_id?, slug?, all?: true, force_seed?: true, dry_run?: true } + */ +import { NextRequest, NextResponse } from 'next/server'; +import { createClient } from '@/lib/supabase/server'; +import { isAgencyRole, userRole } from '@/lib/agency'; +import { resolveActiveTenant } from '@/lib/tenant-context'; +import { + enrichAllTenantBrandbooks, + enrichTenantBrandbook, +} from '@/lib/brandbook-enrich'; +import { ensureCreativesDriveFolders } from '@/lib/creatives-drive'; +import { serviceHeaders, supabaseUrl } from '@/lib/agency'; + +export const dynamic = 'force-dynamic'; +export const maxDuration = 120; + +export async function POST(req: NextRequest) { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + if (!isAgencyRole(userRole(user))) { + return NextResponse.json({ error: 'Solo agencia' }, { status: 403 }); + } + + let body: { + tenant_id?: string; + slug?: string; + all?: boolean; + force_seed?: boolean; + dry_run?: boolean; + ensure_drive?: boolean; + } = {}; + try { + body = await req.json(); + } catch { + body = {}; + } + + const forceSeed = Boolean(body.force_seed); + const dryRun = Boolean(body.dry_run); + const ensureDrive = body.ensure_drive !== false; + + if (body.all) { + const results = await enrichAllTenantBrandbooks({ + forceSeed, + dryRun, + slugFilter: body.slug, + }); + if (ensureDrive && !dryRun) { + for (const r of results) { + if (!r.ok) continue; + await ensureCreativesDriveFolders(r.tenantId, r.slug); + } + } + return NextResponse.json({ + ok: true, + count: results.length, + results, + }); + } + + let tenantId = body.tenant_id?.trim() || ''; + if (!tenantId && body.slug) { + const headers = serviceHeaders(); + const base = supabaseUrl(); + if (headers && base) { + const r = await fetch( + `${base}/rest/v1/tenants?slug=eq.${encodeURIComponent( + body.slug + )}&select=id&limit=1`, + { headers, cache: 'no-store' } + ); + const rows = (await r.json()) as { id: string }[]; + tenantId = rows[0]?.id || ''; + } + } + if (!tenantId) { + const ctx = await resolveActiveTenant(user); + tenantId = ctx.tenantId || ''; + } + if (!tenantId) { + return NextResponse.json( + { error: 'Indica tenant_id, slug, all:true o selecciona cliente' }, + { status: 400 } + ); + } + + const result = await enrichTenantBrandbook({ + tenantId, + forceSeed, + dryRun, + }); + + let drive: { ok: boolean; ads?: string; rrss?: string; warning?: string } | null = + null; + if (ensureDrive && !dryRun && result.ok) { + drive = await ensureCreativesDriveFolders(result.tenantId, result.slug); + } + + return NextResponse.json({ ok: result.ok, result, drive }); +} diff --git a/src/components/BrandOverview.tsx b/src/components/BrandOverview.tsx index 3a68c67..515c7f9 100644 --- a/src/components/BrandOverview.tsx +++ b/src/components/BrandOverview.tsx @@ -99,16 +99,18 @@ function PillRow({ } function websiteFromBook(book: Brandbook): string | null { - const fromChannels = - book.channel_handles?.blog || - book.channels?.blog || - book.channels?.instagram || - ''; - const raw = String(fromChannels || '').trim(); - if (!raw) return null; - if (/^https?:\/\//i.test(raw)) return raw; - if (raw.includes('.') && !raw.startsWith('@')) { - return `https://${raw.replace(/^\/+/, '')}`; + const candidates = [ + book.website, + book.channel_handles?.blog, + book.channels?.blog, + ]; + for (const c of candidates) { + const raw = String(c || '').trim(); + if (!raw) continue; + if (/^https?:\/\//i.test(raw)) return raw; + if (raw.includes('.') && !raw.startsWith('@')) { + return `https://${raw.replace(/^\/+/, '')}`; + } } return null; } @@ -120,6 +122,7 @@ export default function BrandOverview({ missing, onEditSection, onContinueEdit, + embedded = false, }: { book: Brandbook; tenantLabel: string; @@ -127,6 +130,8 @@ export default function BrandOverview({ missing: string[]; onEditSection: (id: BrandbookSectionId) => void; onContinueEdit: () => void; + /** Cuando va dentro del panel de Zona de marca (sin card exterior). */ + embedded?: boolean; }) { const logo = primaryLogo(book); const site = websiteFromBook(book); @@ -167,34 +172,44 @@ export default function BrandOverview({ ).trim(); }, [book]); - return ( -
- -
-
-
-

- Brand Overview -

-

- Perfil de marca -

-

- Resumen visual del brandbook · editable en Detalles del kit -

-
- -
-
+ const header = ( +
+
+

+ Brand Overview +

+

+ Perfil de marca +

+ {!embedded ? ( +

+ Resumen visual del brandbook · editable por secciones +

+ ) : null} +
+ +
+ ); -
+ const content = ( +
{/* Identity */}
@@ -372,10 +387,70 @@ export default function BrandOverview({ )}
-
- {/* Footer progress */} -
+ {/* Copy real ads / RRSS */} +
+
+

+ Copy real (Meta / Google / RRSS) +

+ +
+ {(book.winning_hooks || []).length || + (book.ads_usps || []).length || + (book.ads_ctas || []).length ? ( +
+ {(book.ads_ctas || []).length ? ( +

+ CTAs:{' '} + {(book.ads_ctas || []).slice(0, 6).join(' · ')} +

+ ) : null} + {(book.ads_usps || []).length ? ( + + ) : null} + {(book.winning_hooks || []).length ? ( +
    + {(book.winning_hooks || []).slice(0, 5).map((h) => ( +
  • + {h} +
  • + ))} +
+ ) : null} +
+ ) : ( + + )} +
+
+ ); + + const footer = ( +

@@ -407,6 +482,24 @@ export default function BrandOverview({

+ ); + + if (embedded) { + return ( +
+ {header} + {content} + {footer} +
+ ); + } + + return ( +
+ + {header} + {content} + {footer}
); diff --git a/src/lib/brandbook-ads-insights.ts b/src/lib/brandbook-ads-insights.ts new file mode 100644 index 0000000..5629fa8 --- /dev/null +++ b/src/lib/brandbook-ads-insights.ts @@ -0,0 +1,723 @@ +/** + * Extrae copy real de Meta Ads, Google Ads e Instagram para alimentar + * Zona de marca / brief creativo (winning_hooks, CTAs, ángulos, ejemplos). + * + * Solo datos observados en APIs — no inventa mensajes. + */ +import { resolveMetaAccessToken } from '@/lib/ads-discovery'; +import { resolveGoogleAdsAccessToken } from '@/lib/google-ads-auth'; +import { serviceHeaders, supabaseUrl } from '@/lib/agency'; +import type { Brandbook } from '@/lib/brandbook'; + +const META_GRAPH = 'https://graph.facebook.com/v21.0'; +const GADS_API = 'https://googleads.googleapis.com/v22'; + +export type AdsInsightBundle = { + partial: Partial; + /** Texto para ingestSourceText / skill */ + sourceText: string; + stats: { + metaAds: number; + googleAds: number; + igCaptions: number; + fbPosts: number; + calendarSocial: number; + }; +}; + +function uniq(arr: string[], max = 24): string[] { + const out: string[] = []; + const seen = new Set(); + for (const raw of arr) { + const s = String(raw || '') + .replace(/\s+/g, ' ') + .trim(); + if (s.length < 4) continue; + const key = s.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + out.push(s); + if (out.length >= max) break; + } + return out; +} + +function clip(s: string, n: number): string { + const t = s.trim(); + return t.length <= n ? t : `${t.slice(0, n - 1)}…`; +} + +function humanCta(raw: string): string { + const map: Record = { + LEARN_MORE: 'Más información', + SIGN_UP: 'Registrarse', + CONTACT_US: 'Contactar', + WHATSAPP_MESSAGE: 'WhatsApp', + MESSAGE_PAGE: 'Enviar mensaje', + SHOP_NOW: 'Comprar', + BOOK_TRAVEL: 'Reservar', + GET_OFFER: 'Obtener oferta', + APPLY_NOW: 'Solicitar', + SUBSCRIBE: 'Suscribirse', + DOWNLOAD: 'Descargar', + GET_QUOTE: 'Pedir presupuesto', + SEND_MESSAGE: 'Enviar mensaje', + CALL_NOW: 'Llamar', + }; + const u = raw.toUpperCase().replace(/\s+/g, '_'); + return map[u] || raw.replace(/_/g, ' ').toLowerCase(); +} + +async function metaGet( + path: string, + token: string, + params: Record = {} +): Promise { + const q = new URLSearchParams({ ...params, access_token: token }); + const r = await fetch(`${META_GRAPH}/${path}?${q}`, { cache: 'no-store' }); + if (!r.ok) return null; + return (await r.json()) as T; +} + +function extractFromCreative(creative: Record): { + headlines: string[]; + bodies: string[]; + descriptions: string[]; + ctas: string[]; +} { + const headlines: string[] = []; + const bodies: string[] = []; + const descriptions: string[] = []; + const ctas: string[] = []; + + const push = (arr: string[], v: unknown) => { + const s = String(v || '').trim(); + if (s) arr.push(s); + }; + + push(headlines, creative.title); + push(bodies, creative.body); + if (creative.call_to_action_type) { + push(ctas, humanCta(String(creative.call_to_action_type))); + } + + const spec = (creative.object_story_spec || {}) as Record; + const link = (spec.link_data || {}) as Record; + const video = (spec.video_data || {}) as Record; + push(headlines, link.name); + push(bodies, link.message); + push(descriptions, link.description); + const ctaObj = (link.call_to_action || video.call_to_action) as + | { type?: string } + | undefined; + if (ctaObj?.type) push(ctas, humanCta(ctaObj.type)); + push(bodies, video.message); + + const feed = creative.asset_feed_spec as Record | undefined; + if (feed) { + for (const t of (feed.titles as { text?: string }[]) || []) { + push(headlines, t?.text); + } + for (const b of (feed.bodies as { text?: string }[]) || []) { + push(bodies, b?.text); + } + for (const d of (feed.descriptions as { text?: string }[]) || []) { + push(descriptions, d?.text); + } + for (const c of (feed.call_to_action_types as string[]) || []) { + push(ctas, humanCta(c)); + } + } + + return { headlines, bodies, descriptions, ctas }; +} + +async function loadTenantAdsIds(tenantId: string): Promise<{ + metaAdAccountId?: string; + metaIgUserId?: string; + metaPageId?: string; + googleAdsCustomerId?: string; + linkedinHandle?: string; +}> { + const headers = serviceHeaders(); + const base = supabaseUrl(); + if (!headers || !base) return {}; + + const tRes = await fetch( + `${base}/rest/v1/tenants?id=eq.${encodeURIComponent( + tenantId + )}&select=meta_ad_account_id,meta_ig_user_id,meta_page_id&limit=1`, + { headers, cache: 'no-store' } + ); + const tenants = tRes.ok + ? ((await tRes.json()) as { + meta_ad_account_id?: string | null; + meta_ig_user_id?: string | null; + meta_page_id?: string | null; + }[]) + : []; + const t = tenants[0] || {}; + + const iRes = await fetch( + `${base}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent( + tenantId + )}&provider=in.(meta_ads,google_ads,linkedin)&status=eq.connected&select=provider,external_account_id,label`, + { headers, cache: 'no-store' } + ); + const ints = iRes.ok + ? ((await iRes.json()) as { + provider: string; + external_account_id?: string | null; + label?: string | null; + }[]) + : []; + + const meta = + ints.find((i) => i.provider === 'meta_ads')?.external_account_id || + t.meta_ad_account_id || + undefined; + const gads = ints.find((i) => i.provider === 'google_ads') + ?.external_account_id; + const li = ints.find((i) => i.provider === 'linkedin'); + + return { + metaAdAccountId: meta ? String(meta) : undefined, + metaIgUserId: t.meta_ig_user_id || undefined, + metaPageId: t.meta_page_id || undefined, + googleAdsCustomerId: gads ? String(gads).replace(/-/g, '') : undefined, + linkedinHandle: + li?.external_account_id || li?.label + ? String(li.external_account_id || li.label) + : undefined, + }; +} + +async function collectMetaAdsCopy( + actId: string, + token: string +): Promise<{ + headlines: string[]; + bodies: string[]; + descriptions: string[]; + ctas: string[]; + campaignNames: string[]; + count: number; +}> { + const act = actId.startsWith('act_') ? actId : `act_${actId}`; + const headlines: string[] = []; + const bodies: string[] = []; + const descriptions: string[] = []; + const ctas: string[] = []; + const campaignNames: string[] = []; + let count = 0; + + // Prefer ACTIVE, then PAUSED (histórico reciente) + for (const status of ['ACTIVE', 'PAUSED'] as const) { + const raw = await metaGet<{ + data?: { + id?: string; + name?: string; + campaign?: { name?: string }; + creative?: Record; + }[]; + }>(`${act}/ads`, token, { + fields: + 'id,name,effective_status,campaign{name},creative{id,title,body,call_to_action_type,object_story_spec,asset_feed_spec}', + effective_status: JSON.stringify([status]), + limit: '40', + }); + for (const ad of raw?.data || []) { + count += 1; + if (ad.campaign?.name) campaignNames.push(ad.campaign.name); + let creative = (ad.creative || {}) as Record; + const cid = String(creative.id || ''); + if (cid) { + const detail = await metaGet>(cid, token, { + fields: + 'id,title,body,call_to_action_type,object_story_spec,asset_feed_spec', + }); + if (detail) creative = { ...creative, ...detail }; + } + const pack = extractFromCreative(creative); + headlines.push(...pack.headlines); + bodies.push(...pack.bodies); + descriptions.push(...pack.descriptions); + ctas.push(...pack.ctas); + } + if (count >= 25) break; + } + + return { headlines, bodies, descriptions, ctas, campaignNames, count }; +} + +async function collectIgCaptions( + igUserId: string, + token: string +): Promise { + const media = await metaGet<{ + data?: { caption?: string }[]; + }>(`${igUserId}/media`, token, { + fields: 'caption,timestamp', + limit: '24', + }); + return (media?.data || []) + .map((m) => String(m.caption || '').trim()) + .filter((c) => c.length >= 12); +} + +async function collectFbPosts( + pageId: string, + token: string +): Promise { + const fields = + 'id,message,created_time,permalink_url,status_type'; + for (const edge of ['published_posts', 'posts'] as const) { + const raw = await metaGet<{ data?: { message?: string }[] }>( + `${pageId}/${edge}`, + token, + { fields, limit: '25' } + ); + const msgs = (raw?.data || []) + .map((p) => String(p.message || '').trim()) + .filter((m) => m.length >= 12); + if (msgs.length) return msgs; + } + return []; +} + +/** Copy RRSS ya planificado/publicado en calendarios (IG/FB/LinkedIn). */ +async function collectCalendarSocialCopy(tenantId: string): Promise<{ + texts: string[]; + linkedin: string[]; + byPlatform: Record; +}> { + const headers = serviceHeaders(); + const base = supabaseUrl(); + const empty = { texts: [] as string[], linkedin: [] as string[], byPlatform: {} }; + if (!headers || !base) return empty; + + try { + const r = await fetch( + `${base}/rest/v1/documents?tenant_id=eq.${encodeURIComponent( + tenantId + )}&type=eq.calendar&select=content&order=updated_at.desc&limit=8`, + { headers, cache: 'no-store' } + ); + if (!r.ok) return empty; + const rows = (await r.json()) as { content?: unknown }[]; + const texts: string[] = []; + const linkedin: string[] = []; + const byPlatform: Record = {}; + + for (const row of rows) { + const c = + typeof row.content === 'string' + ? JSON.parse(row.content) + : row.content; + const pieces = Array.isArray((c as { pieces?: unknown })?.pieces) + ? ((c as { + pieces: { + platform?: string; + title?: string; + hook?: string; + body?: string; + caption?: string; + cta?: string; + }[]; + }).pieces) + : []; + for (const p of pieces) { + const plat = String(p.platform || '').toLowerCase() || 'unknown'; + byPlatform[plat] = (byPlatform[plat] || 0) + 1; + const blob = [p.hook, p.title, p.body || p.caption, p.cta] + .map((x) => String(x || '').trim()) + .filter((x) => x.length >= 8) + .join(' · '); + if (blob.length < 12) continue; + texts.push(blob); + if (/linkedin|li\b/.test(plat)) linkedin.push(blob); + } + } + return { + texts: uniq(texts, 20), + linkedin: uniq(linkedin, 12), + byPlatform, + }; + } catch { + return empty; + } +} + +async function collectGoogleAdsCopy( + customerId: string +): Promise<{ headlines: string[]; descriptions: string[]; count: number }> { + const login = + process.env.GOOGLE_ADS_LOGIN_CUSTOMER_ID?.replace(/-/g, '') || ''; + const dev = process.env.GOOGLE_ADS_DEVELOPER_TOKEN?.trim() || ''; + if (!dev) return { headlines: [], descriptions: [], count: 0 }; + + let accessToken: string; + try { + accessToken = await resolveGoogleAdsAccessToken(); + } catch { + return { headlines: [], descriptions: [], count: 0 }; + } + + const query = ` +SELECT + ad_group_ad.ad.responsive_search_ad.headlines, + ad_group_ad.ad.responsive_search_ad.descriptions, + ad_group_ad.ad.responsive_display_ad.headlines, + ad_group_ad.ad.responsive_display_ad.descriptions, + ad_group_ad.status +FROM ad_group_ad +WHERE ad_group_ad.status != 'REMOVED' + AND campaign.status != 'REMOVED' +LIMIT 50 +`.trim(); + + const headers: Record = { + Authorization: `Bearer ${accessToken}`, + 'developer-token': dev, + 'Content-Type': 'application/json', + }; + if (login) headers['login-customer-id'] = login; + + const r = await fetch( + `${GADS_API}/customers/${customerId}/googleAds:search`, + { + method: 'POST', + headers, + body: JSON.stringify({ query }), + cache: 'no-store', + } + ); + if (!r.ok) return { headlines: [], descriptions: [], count: 0 }; + const j = (await r.json()) as { + results?: { + adGroupAd?: { + ad?: { + responsiveSearchAd?: { + headlines?: { text?: string }[]; + descriptions?: { text?: string }[]; + }; + responsiveDisplayAd?: { + headlines?: { text?: string }[]; + descriptions?: { text?: string }[]; + }; + }; + }; + }[]; + }; + + const headlines: string[] = []; + const descriptions: string[] = []; + let count = 0; + for (const row of j.results || []) { + count += 1; + const ad = row.adGroupAd?.ad; + for (const h of ad?.responsiveSearchAd?.headlines || []) { + if (h.text) headlines.push(h.text); + } + for (const d of ad?.responsiveSearchAd?.descriptions || []) { + if (d.text) descriptions.push(d.text); + } + for (const h of ad?.responsiveDisplayAd?.headlines || []) { + if (h.text) headlines.push(h.text); + } + for (const d of ad?.responsiveDisplayAd?.descriptions || []) { + if (d.text) descriptions.push(d.text); + } + } + return { headlines, descriptions, count }; +} + +/** Ángulos a partir de nombres de campaña (TOFU/MOFU/BOFU, producto, etc.). */ +function anglesFromCampaignNames(names: string[]): string[] { + const angles: string[] = []; + for (const n of names) { + const lower = n.toLowerCase(); + if (/tofu|awareness|traf[ií]co|alcance/.test(lower)) { + angles.push('TOFU / tráfico / awareness'); + } + if (/mofu|consideraci|interacci|engagement/.test(lower)) { + angles.push('MOFU / consideración'); + } + if (/bofu|conversi|leads?|mensaje|whatsapp|ventas?/.test(lower)) { + angles.push('BOFU / conversión / leads'); + } + if (/retarget|remarket|rmkt/.test(lower)) angles.push('Retargeting'); + // keep short campaign fragments as angle hints + const clean = n + .replace(/[_-]+/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + if (clean.length >= 8 && clean.length <= 60) angles.push(clean); + } + return uniq(angles, 12); +} + +/** + * Recoge insights de ads/RRSS y propone campos de brandbook. + * Los campos de copy se deben aplicar con prioridad sobre síntesis vacía + * (ver applyAdsInsightsToBrandbook). + */ +export async function collectAdsInsightsForTenant( + tenantId: string +): Promise { + const ids = await loadTenantAdsIds(tenantId); + const token = await resolveMetaAccessToken().catch(() => null); + + const metaHeadlines: string[] = []; + const metaBodies: string[] = []; + const metaDescs: string[] = []; + const metaCtas: string[] = []; + const campaignNames: string[] = []; + let metaCount = 0; + let igCaptions: string[] = []; + let fbPosts: string[] = []; + let gadsHeadlines: string[] = []; + let gadsDescs: string[] = []; + let gadsCount = 0; + const calendarSocial = await collectCalendarSocialCopy(tenantId); + + if (token && ids.metaAdAccountId) { + try { + const m = await collectMetaAdsCopy(ids.metaAdAccountId, token); + metaHeadlines.push(...m.headlines); + metaBodies.push(...m.bodies); + metaDescs.push(...m.descriptions); + metaCtas.push(...m.ctas); + campaignNames.push(...m.campaignNames); + metaCount = m.count; + } catch { + /* ignore */ + } + } + + if (token && ids.metaIgUserId) { + try { + igCaptions = await collectIgCaptions(ids.metaIgUserId, token); + } catch { + /* ignore */ + } + } + + if (token && ids.metaPageId) { + try { + fbPosts = await collectFbPosts(ids.metaPageId, token); + } catch { + /* ignore */ + } + } + + if (ids.googleAdsCustomerId) { + try { + const g = await collectGoogleAdsCopy(ids.googleAdsCustomerId); + gadsHeadlines = g.headlines; + gadsDescs = g.descriptions; + gadsCount = g.count; + } catch { + /* ignore */ + } + } + + const socialBodies = [ + ...igCaptions, + ...fbPosts, + ...calendarSocial.texts, + ]; + + const winning_hooks = uniq( + [ + ...metaBodies.map((b) => clip(b, 180)), + ...metaHeadlines.map((h) => clip(h, 80)), + ...gadsHeadlines.map((h) => clip(h, 80)), + ...socialBodies.slice(0, 8).map((b) => clip(b, 180)), + ], + 16 + ); + + const example_good = uniq( + [ + ...metaBodies.slice(0, 8).map((b) => clip(b, 280)), + ...gadsDescs.slice(0, 6).map((d) => clip(d, 200)), + ...igCaptions.slice(0, 6).map((c) => clip(c, 280)), + ...fbPosts.slice(0, 5).map((c) => clip(c, 280)), + ...calendarSocial.linkedin.slice(0, 5).map((c) => clip(c, 280)), + ...calendarSocial.texts.slice(0, 4).map((c) => clip(c, 280)), + ], + 14 + ); + + const ads_ctas = uniq([...metaCtas], 10); + const preferred_angles = anglesFromCampaignNames(campaignNames); + const ads_angles = preferred_angles; + const slogan_variants = uniq( + [...metaHeadlines, ...gadsHeadlines].map((h) => clip(h, 60)), + 12 + ); + + // USPs cortos: titulares Meta/Google (son claims ya usados en ads) + const ads_usps = uniq( + [...metaHeadlines, ...gadsHeadlines] + .filter((h) => h.length >= 8 && h.length <= 90) + .map((h) => clip(h, 90)), + 10 + ); + + const linkedin_themes = uniq( + calendarSocial.linkedin + .map((t) => clip(t.split(/[·|]/)[0] || t, 80)) + .filter((t) => t.length >= 8), + 8 + ); + + const lines: string[] = [ + '=== COPY REAL DESDE ADS / RRSS (no inventado) ===', + `Meta ads: ${metaCount} · Google ads: ${gadsCount} · IG captions: ${igCaptions.length} · FB posts: ${fbPosts.length} · Calendario RRSS: ${calendarSocial.texts.length}`, + ]; + if (ids.linkedinHandle) { + lines.push(`LinkedIn company (integración): ${ids.linkedinHandle}`); + } + if (winning_hooks.length) { + lines.push('', 'Hooks / primary texts / titulares observados:'); + winning_hooks.forEach((h, i) => lines.push(`${i + 1}. ${h}`)); + } + if (ads_ctas.length) { + lines.push('', `CTAs Meta observados: ${ads_ctas.join(', ')}`); + } + if (preferred_angles.length) { + lines.push('', `Ángulos (nombres de campaña): ${preferred_angles.join(' · ')}`); + } + if (igCaptions.length) { + lines.push('', 'Captions IG recientes (muestra):'); + igCaptions.slice(0, 5).forEach((c, i) => lines.push(`${i + 1}. ${clip(c, 200)}`)); + } + if (fbPosts.length) { + lines.push('', 'Posts Facebook recientes (muestra):'); + fbPosts.slice(0, 5).forEach((c, i) => lines.push(`${i + 1}. ${clip(c, 200)}`)); + } + if (calendarSocial.linkedin.length) { + lines.push('', 'Copy LinkedIn (calendario / planificado):'); + calendarSocial.linkedin + .slice(0, 5) + .forEach((c, i) => lines.push(`${i + 1}. ${clip(c, 200)}`)); + } else if (ids.linkedinHandle) { + lines.push( + '', + `LinkedIn: página conectada (${ids.linkedinHandle}). Sin API de posts — usar temas del calendario cuando existan.` + ); + } + if (gadsHeadlines.length) { + lines.push('', 'Headlines Google Ads (muestra):'); + uniq(gadsHeadlines, 10).forEach((h, i) => lines.push(`${i + 1}. ${h}`)); + } + + const partial: Partial = {}; + if (winning_hooks.length) partial.winning_hooks = winning_hooks; + if (example_good.length) partial.example_good = example_good; + if (ads_ctas.length) { + partial.ads_ctas = ads_ctas; + partial.allowed_ctas = ads_ctas; + } + if (preferred_angles.length) { + partial.preferred_angles = preferred_angles; + partial.ads_angles = ads_angles; + } + if (slogan_variants.length) partial.slogan_variants = slogan_variants; + if (ads_usps.length) partial.ads_usps = ads_usps; + if (linkedin_themes.length) partial.linkedin_themes = linkedin_themes; + + // Campos editoriales derivados SOLO de copy observado (relleno si vacíos en apply) + if (ads_usps.length) { + partial.usp_differentiators = ads_usps.slice(0, 5).join(' · '); + partial.differentiators = ads_usps.slice(0, 8); + partial.value_proposition = ads_usps.slice(0, 3).join(' · '); + } + if (preferred_angles.length) { + partial.messaging_pillars = preferred_angles.slice(0, 6); + } else if (ads_usps.length) { + partial.messaging_pillars = ads_usps.slice(0, 5); + } + const proof = uniq( + [...metaBodies, ...gadsDescs, ...igCaptions] + .filter((t) => /\d/.test(t) && t.length >= 20) + .map((t) => clip(t, 160)), + 6 + ); + if (proof.length) partial.proof_points = proof; + if (slogan_variants.length && !partial.tagline) { + // no: tagline no se inventa desde un headline suelto + } + + return { + partial, + sourceText: lines.join('\n'), + stats: { + metaAds: metaCount, + googleAds: gadsCount, + igCaptions: igCaptions.length, + fbPosts: fbPosts.length, + calendarSocial: calendarSocial.texts.length, + }, + }; +} + +/** + * Aplica insights de ads al brandbook. + * - Por defecto rellena vacíos. + * - Si `overwriteAdsFields`, pisa hooks/CTAs/ángulos/usps/ejemplos con copy real + * (prioridad sobre síntesis editorial previa). + */ +export function applyAdsInsightsToBrandbook( + book: Brandbook, + bundle: AdsInsightBundle, + opts?: { overwriteAdsFields?: boolean } +): Brandbook { + const overwrite = opts?.overwriteAdsFields !== false; + const p = bundle.partial; + const out: Brandbook = { ...book }; + + const applyArr = ( + key: keyof Brandbook, + next?: string[], + mode: 'overwrite' | 'empty' = 'overwrite' + ) => { + if (!next?.length) return; + const cur = out[key] as string[] | undefined; + if (mode === 'overwrite') { + if (overwrite || !cur?.length) { + (out as Record)[key] = next; + } + } else if (!cur?.length) { + (out as Record)[key] = next; + } + }; + + const applyStrEmpty = (key: keyof Brandbook, next?: string) => { + if (!next?.trim()) return; + const cur = String(out[key] || '').trim(); + if (!cur) (out as Record)[key] = next.trim(); + }; + + // Copy de ads: prioridad (overwrite) + applyArr('winning_hooks', p.winning_hooks, 'overwrite'); + applyArr('example_good', p.example_good, 'overwrite'); + applyArr('ads_ctas', p.ads_ctas, 'overwrite'); + applyArr('allowed_ctas', p.allowed_ctas, 'overwrite'); + applyArr('preferred_angles', p.preferred_angles, 'overwrite'); + applyArr('ads_angles', p.ads_angles, 'overwrite'); + applyArr('slogan_variants', p.slogan_variants, 'overwrite'); + applyArr('ads_usps', p.ads_usps, 'overwrite'); + applyArr('linkedin_themes', p.linkedin_themes, 'overwrite'); + + // Editoriales: solo si vacíos + applyStrEmpty('usp_differentiators', p.usp_differentiators); + applyStrEmpty('value_proposition', p.value_proposition); + applyArr('differentiators', p.differentiators, 'empty'); + applyArr('messaging_pillars', p.messaging_pillars, 'empty'); + applyArr('proof_points', p.proof_points, 'empty'); + + return out; +} diff --git a/src/lib/brandbook-enrich.ts b/src/lib/brandbook-enrich.ts new file mode 100644 index 0000000..8d1a42f --- /dev/null +++ b/src/lib/brandbook-enrich.ts @@ -0,0 +1,1090 @@ +/** + * Auto-fill Zona de marca from every source we already have in the system: + * seed DNA, creative_style, tenant landing/logo, Meta/IG/FB/LinkedIn integrations, + * Google Ads label, Drive brand folder names, Superhuman aliases, calendar series. + * + * Strategy: fill empty fields only (never overwrite operator-edited content) + * unless opts.forceSeed is true (re-apply seed gaps after wipe). + */ +import { + BRANDBOOK_DOC_TITLE, + BRANDBOOK_DOC_TYPE, + brandbookCompleteness, + ingestSourceText, + newId, + parseBrandbook, + seedBrandbook, + type Brandbook, + type ColorToken, + type FontToken, +} from '@/lib/brandbook'; +import { + normalizeCreativeStyle, + type ClientCreativeStyle, +} from '@/lib/creative-style'; +import { resolveMetaAccessToken } from '@/lib/ads-discovery'; +import { + applyAdsInsightsToBrandbook, + collectAdsInsightsForTenant, +} from '@/lib/brandbook-ads-insights'; +import { + driveConfigured, + getTenantDriveFolderId, + listFilesInFolder, +} from '@/lib/google-drive'; +import { serviceHeaders, supabaseUrl } from '@/lib/agency'; +import { readFileSync, existsSync } from 'fs'; +import { join } from 'path'; + +const META_GRAPH = 'https://graph.facebook.com/v21.0'; + +/** Colores/fuentes scrapeados de webs (scripts/data/scraped_brand_signals.json). */ +type ScrapedSignal = { + site?: string; + theme_color?: string | null; + css_vars?: string[]; + top_hex?: [string, number][]; + fonts?: [string, number][]; +}; + +const GENERIC_HEX = new Set( + [ + '#000000', + '#ffffff', + '#abb8c3', + '#f78da7', + '#cf2e2e', // Gutenberg defaults + '#000', + '#fff', + '#fafafa', + '#fbfbfb', + '#e7e7e7', + ].map((h) => h.toLowerCase()) +); + +function loadScrapedSignals(): Record { + try { + const p = join(process.cwd(), 'scripts/data/scraped_brand_signals.json'); + if (!existsSync(p)) return {}; + return JSON.parse(readFileSync(p, 'utf8')) as Record; + } catch { + return {}; + } +} + +function scrapedToPartial(slug: string): Partial { + const sig = loadScrapedSignals()[slug]; + if (!sig) return {}; + const hexes: string[] = []; + if (sig.theme_color) hexes.push(sig.theme_color); + for (const h of sig.css_vars || []) hexes.push(h); + for (const [h] of sig.top_hex || []) hexes.push(h); + const seen = new Set(); + const colors: ColorToken[] = []; + for (const raw of hexes) { + const hex = String(raw || '').trim(); + if (!/^#[0-9a-fA-F]{3,8}$/i.test(hex)) continue; + const norm = + hex.length === 4 + ? `#${hex[1]}${hex[1]}${hex[2]}${hex[2]}${hex[3]}${hex[3]}`.toUpperCase() + : hex.slice(0, 7).toUpperCase(); + if (GENERIC_HEX.has(norm.toLowerCase())) continue; + if (seen.has(norm)) continue; + seen.add(norm); + colors.push({ + id: newId('col'), + name: colors.length === 0 ? 'Primario (web)' : `Web ${colors.length + 1}`, + hex: norm, + role: colors.length === 0 ? 'primary' : colors.length === 1 ? 'accent' : 'secondary', + usage: 'Extraído del CSS / theme de la web oficial', + }); + if (colors.length >= 6) break; + } + + const fonts: FontToken[] = []; + for (const [fam] of sig.fonts || []) { + const family = String(fam || '') + .replace(/!important/gi, '') + .replace(/['"]/g, '') + .trim(); + if (!family || /var\s*\(|sans-serif|serif|monospace|inherit|system/i.test(family)) { + continue; + } + if (fonts.some((f) => f.family.toLowerCase() === family.toLowerCase())) continue; + fonts.push({ + id: newId('font'), + family, + role: fonts.length === 0 ? 'headline' : 'body', + weights: '400,700', + source: 'google', + url_or_notes: 'Detectada en web oficial', + }); + if (fonts.length >= 3) break; + } + + const partial: Partial = {}; + if (colors.length) partial.colors = colors; + if (fonts.length) { + partial.fonts_kit = fonts; + partial.typography_headline = fonts[0]?.family; + partial.typography_body = fonts[1]?.family || fonts[0]?.family; + } + if (sig.site) partial.website = sig.site; + return partial; +} + +export type BrandEnrichSource = + | 'seed' + | 'creative_style' + | 'tenant' + | 'integrations' + | 'meta_graph' + | 'ads_insights' + | 'drive' + | 'calendar' + | 'website' + | 'superhuman_alias'; + +export type BrandEnrichResult = { + ok: boolean; + tenantId: string; + slug: string; + created: boolean; + sources: BrandEnrichSource[]; + completenessBefore: number; + completenessAfter: number; + filledFields: string[]; + warning?: string; +}; + +function isBlank(v: unknown): boolean { + if (v == null) return true; + if (typeof v === 'string') return !v.trim(); + if (Array.isArray(v)) return v.length === 0; + return false; +} + +function fillStr(cur: string | undefined, next: string | undefined | null): string { + if (!isBlank(cur)) return String(cur); + return String(next || '').trim(); +} + +function fillArr(cur: string[] | undefined, next: string[] | undefined): string[] { + if (cur && cur.length) return cur; + return (next || []).map((x) => String(x || '').trim()).filter(Boolean); +} + +function fillColors( + cur: ColorToken[] | undefined, + next: ColorToken[] | undefined +): ColorToken[] { + if (cur && cur.length) return cur; + return next || []; +} + +function fillFonts( + cur: FontToken[] | undefined, + next: FontToken[] | undefined +): FontToken[] { + if (cur && cur.length) return cur; + return next || []; +} + +/** Merge overlay into base, only filling blank fields. */ +export function mergeFillEmpty( + base: Brandbook, + overlay: Partial, + track?: string[] +): Brandbook { + const note = (field: string, wasEmpty: boolean) => { + if (wasEmpty && track && !track.includes(field)) track.push(field); + }; + + const out: Brandbook = { ...base }; + + const strKeys: (keyof Brandbook)[] = [ + 'tagline', + 'elevator_pitch', + 'brand_promise', + 'positioning', + 'archetype', + 'origin_story', + 'mission', + 'vision', + 'vertical', + 'b2b_offering', + 'b2c_offering', + 'usp_differentiators', + 'current_priorities', + 'website', + 'tone', + 'copy_structure_rules', + 'english_terms_policy', + 'visual_notes', + 'logo_usage', + 'photography_style', + 'illustration_style', + 'icon_style', + 'surface_rules', + 'image_text_rules', + 'typography_headline', + 'typography_body', + 'icp', + 'geo', + 'customer_journey', + 'customer_frustrations', + 'desired_transformation', + 'value_proposition', + 'guarantee', + 'pricing_notes', + 'price_and_offer_framing', + 'content_matrix', + 'channel_cadence', + 'landing_notes', + 'abm_notes', + 'blog_cadence', + 'legal_notes', + 'sector_rules', + 'image_rights', + 'emoji_policy', + 'testimonials_policy', + 'freeform_notes', + ]; + + for (const k of strKeys) { + const was = isBlank(out[k] as string); + const next = fillStr(out[k] as string, overlay[k] as string); + if (was && next) { + (out as Record)[k] = next; + note(k, true); + } + } + + const arrKeys: (keyof Brandbook)[] = [ + 'values', + 'personality', + 'voice_do', + 'voice_dont', + 'vocabulary_prefer', + 'vocabulary_avoid', + 'messaging_pillars', + 'example_good', + 'example_bad', + 'slogan_variants', + 'power_words', + 'allowed_ctas', + 'imagery_do', + 'imagery_dont', + 'languages', + 'jobs_to_be_done', + 'buying_triggers', + 'barriers', + 'products_services', + 'differentiators', + 'proof_points', + 'packages', + 'competitive_alternatives', + 'pillars_rrss', + 'series_recurrentes', + 'formats_priority', + 'taboo_topics', + 'seasonal_hooks', + 'channel_kpis', + 'ads_usps', + 'ads_objections', + 'ads_ctas', + 'ads_offers', + 'ads_angles', + 'ads_funnel', + 'ads_formats', + 'retargeting_angles', + 'winning_hooks', + 'preferred_angles', + 'linkedin_themes', + 'linkedin_formats', + 'thought_leadership', + 'buyer_roles', + 'case_study_angles', + 'linkedin_cta', + 'blog_themes', + 'blog_seo_keywords', + 'content_clusters', + 'blog_cta', + 'claims_forbidden', + 'required_disclaimers', + 'competitors_never_mention', + ]; + + for (const k of arrKeys) { + const was = isBlank(out[k] as string[]); + const next = fillArr(out[k] as string[], overlay[k] as string[]); + if (was && next.length) { + (out as Record)[k] = next; + note(k, true); + } + } + + if (isBlank(out.colors) && overlay.colors?.length) { + out.colors = fillColors(out.colors, overlay.colors); + note('colors', true); + } + if (isBlank(out.fonts_kit) && overlay.fonts_kit?.length) { + out.fonts_kit = fillFonts(out.fonts_kit, overlay.fonts_kit); + note('fonts_kit', true); + } + + if (isBlank(out.b2b_personas) && overlay.b2b_personas?.length) { + out.b2b_personas = overlay.b2b_personas; + note('b2b_personas', true); + } + if (isBlank(out.b2c_personas) && overlay.b2c_personas?.length) { + out.b2c_personas = overlay.b2c_personas; + note('b2c_personas', true); + } + if (isBlank(out.personas) && overlay.personas?.length) { + out.personas = overlay.personas; + note('personas', true); + } + if (isBlank(out.objections) && overlay.objections?.length) { + out.objections = overlay.objections; + note('objections', true); + } + + out.channels = { + ...out.channels, + ...Object.fromEntries( + Object.entries(overlay.channels || {}).filter( + ([k, v]) => isBlank(out.channels[k as keyof typeof out.channels]) && v + ) + ), + }; + out.channel_handles = { + ...out.channel_handles, + ...Object.fromEntries( + Object.entries(overlay.channel_handles || {}).filter( + ([k, v]) => + isBlank(out.channel_handles[k as keyof typeof out.channel_handles]) && + v + ) + ), + }; + + if (!out.brand_name && overlay.brand_name) { + out.brand_name = overlay.brand_name; + note('brand_name', true); + } + + out.updated_at = new Date().toISOString(); + return out; +} + +function styleToPartial(style: ClientCreativeStyle): Partial { + const colors: ColorToken[] = (style.visual?.colors || []).map((c, i) => ({ + id: newId('col'), + name: c.name || `Color ${i + 1}`, + hex: c.hex, + role: i === 0 ? 'primary' : i === 1 ? 'accent' : 'secondary', + usage: c.usage || '', + })); + const fonts: FontToken[] = []; + if (style.visual?.typography_headline) { + fonts.push({ + id: newId('font'), + family: style.visual.typography_headline.split(/[(\s]/)[0] || style.visual.typography_headline, + role: 'headline', + weights: '700', + source: 'other', + url_or_notes: style.visual.typography_headline, + }); + } + if (style.visual?.typography_body) { + fonts.push({ + id: newId('font'), + family: style.visual.typography_body.split(/[(\s]/)[0] || style.visual.typography_body, + role: 'body', + weights: '400', + source: 'other', + url_or_notes: style.visual.typography_body, + }); + } + + return { + elevator_pitch: style.identity?.summary, + website: style.identity?.website, + b2b_offering: style.identity?.b2b_offering, + b2c_offering: style.identity?.b2c_offering, + usp_differentiators: style.identity?.usp_differentiators, + current_priorities: style.identity?.current_priorities, + price_and_offer_framing: style.identity?.price_and_offer_framing, + tone: style.voice?.tone, + copy_structure_rules: style.voice?.copy_structure_rules, + english_terms_policy: style.voice?.english_terms_policy, + power_words: style.voice?.power_words, + allowed_ctas: style.voice?.allowed_ctas, + customer_frustrations: style.audiences?.customer_frustrations, + desired_transformation: style.audiences?.desired_transformation, + b2b_personas: style.audiences?.b2b_personas, + b2c_personas: style.audiences?.b2c_personas, + ads_usps: style.arguments?.winning_arguments, + winning_hooks: style.arguments?.winning_hooks, + preferred_angles: style.arguments?.preferred_angles, + proof_points: style.arguments?.proof_points, + objections: style.arguments?.objections, + claims_forbidden: style.prohibitions?.forbidden_items, + competitors_never_mention: style.prohibitions?.competitors_never_mention, + emoji_policy: style.prohibitions?.emoji_policy, + testimonials_policy: style.prohibitions?.testimonials_policy, + colors, + fonts_kit: fonts, + surface_rules: style.visual?.surface_rules, + typography_headline: style.visual?.typography_headline, + typography_body: style.visual?.typography_body, + logo_usage: style.visual?.logo_rules, + image_text_rules: style.visual?.image_text_rules, + photography_style: style.image_ai?.photo_style, + }; +} + +function normalizeHandle(raw: string, platform: 'instagram' | 'facebook' | 'linkedin' | 'tiktok' | 'youtube'): string { + const s = raw.trim(); + if (!s) return ''; + if (/^https?:\/\//i.test(s)) return s; + if (platform === 'instagram') { + const u = s.replace(/^@/, ''); + return `https://instagram.com/${u}`; + } + if (platform === 'facebook') { + if (/^\d+$/.test(s)) return `https://facebook.com/${s}`; + return s.includes('facebook.com') ? s : `https://facebook.com/${s}`; + } + if (platform === 'linkedin') { + if (/^\d+$/.test(s)) return s; + return s.includes('linkedin.com') ? s : `https://linkedin.com/company/${s}`; + } + return s; +} + +async function fetchMetaProfile(opts: { + pageId?: string | null; + igUserId?: string | null; +}): Promise> { + const token = await resolveMetaAccessToken(); + if (!token) return {}; + const partial: Partial = { + channel_handles: { + instagram: '', + facebook: '', + linkedin: '', + tiktok: '', + blog: '', + youtube: '', + }, + channels: { + instagram: '', + facebook: '', + linkedin: '', + tiktok: '', + blog: '', + youtube: '', + email: '', + other: '', + }, + }; + const notes: string[] = []; + + try { + if (opts.pageId) { + const r = await fetch( + `${META_GRAPH}/${encodeURIComponent(opts.pageId)}?fields=name,about,website,link,username,fan_count&access_token=${encodeURIComponent(token)}`, + { cache: 'no-store' } + ); + if (r.ok) { + const j = (await r.json()) as { + name?: string; + about?: string; + website?: string; + link?: string; + username?: string; + }; + if (j.website) partial.website = j.website; + if (j.about) { + partial.elevator_pitch = j.about.slice(0, 400); + notes.push(`FB about: ${j.about.slice(0, 500)}`); + } + if (j.link || j.username) { + partial.channel_handles!.facebook = j.link || `https://facebook.com/${j.username}`; + } + if (j.name) notes.push(`FB page: ${j.name}`); + } + } + } catch { + /* ignore */ + } + + try { + if (opts.igUserId) { + const r = await fetch( + `${META_GRAPH}/${encodeURIComponent(opts.igUserId)}?fields=username,name,biography,website,followers_count&access_token=${encodeURIComponent(token)}`, + { cache: 'no-store' } + ); + if (r.ok) { + const j = (await r.json()) as { + username?: string; + name?: string; + biography?: string; + website?: string; + }; + if (j.username) { + partial.channel_handles!.instagram = normalizeHandle( + j.username, + 'instagram' + ); + } + if (j.website && !partial.website) partial.website = j.website; + if (j.biography) { + if (!partial.elevator_pitch) { + partial.elevator_pitch = j.biography.slice(0, 400); + } + notes.push(`IG bio: ${j.biography.slice(0, 500)}`); + } + } + } + } catch { + /* ignore */ + } + + if (notes.length) { + partial.freeform_notes = notes.join('\n'); + } + return partial; +} + +async function driveBrandHints(tenantId: string): Promise { + if (!driveConfigured()) return null; + try { + const linked = await getTenantDriveFolderId(tenantId); + if (!linked?.folderId) return null; + const files = await listFilesInFolder(linked.folderId); + const names = files + .map((f) => f.name) + .filter((n) => + /brand|marca|logo|identidad|kit|manual|guideline|paleta|tipograf/i.test( + n + ) + ) + .slice(0, 30); + if (!names.length) return null; + return `Archivos Drive (marca/identidad):\n${names.join('\n')}`; + } catch { + return null; + } +} + +async function calendarSeriesHints( + tenantId: string +): Promise<{ series: string[]; pillars: string[] }> { + const headers = serviceHeaders(); + const base = supabaseUrl(); + if (!headers || !base) return { series: [], pillars: [] }; + try { + const r = await fetch( + `${base}/rest/v1/documents?tenant_id=eq.${encodeURIComponent( + tenantId + )}&type=eq.calendar&select=content&order=updated_at.desc&limit=6`, + { headers, cache: 'no-store' } + ); + if (!r.ok) return { series: [], pillars: [] }; + const rows = (await r.json()) as { content?: unknown }[]; + const series = new Set(); + const pillars = new Set(); + for (const row of rows) { + const c = + typeof row.content === 'string' + ? JSON.parse(row.content) + : row.content; + const pieces = Array.isArray((c as { pieces?: unknown })?.pieces) + ? ((c as { pieces: { series?: string; pillar?: string; format?: string }[] }) + .pieces) + : []; + for (const p of pieces) { + if (p.series) series.add(String(p.series).trim()); + if (p.pillar) pillars.add(String(p.pillar).trim()); + } + } + return { + series: [...series].filter(Boolean).slice(0, 24), + pillars: [...pillars].filter(Boolean).slice(0, 16), + }; + } catch { + return { series: [], pillars: [] }; + } +} + +export async function enrichTenantBrandbook(opts: { + tenantId: string; + forceSeed?: boolean; + dryRun?: boolean; +}): Promise { + const headers = serviceHeaders(); + const base = supabaseUrl(); + if (!headers || !base) { + return { + ok: false, + tenantId: opts.tenantId, + slug: '', + created: false, + sources: [], + completenessBefore: 0, + completenessAfter: 0, + filledFields: [], + warning: 'Service role no configurado', + }; + } + + const tRes = await fetch( + `${base}/rest/v1/tenants?id=eq.${encodeURIComponent( + opts.tenantId + )}&select=id,name,slug,default_landing_url,meta_page_id,meta_ig_user_id,creative_style,settings,logo_url,agency_notes&limit=1`, + { headers, cache: 'no-store' } + ); + const tenants = (await tRes.json()) as { + id: string; + name: string; + slug: string; + default_landing_url?: string | null; + meta_page_id?: string | null; + meta_ig_user_id?: string | null; + creative_style?: unknown; + settings?: { creative_style?: unknown }; + logo_url?: string | null; + agency_notes?: string | null; + }[]; + const tenant = tenants[0]; + if (!tenant) { + return { + ok: false, + tenantId: opts.tenantId, + slug: '', + created: false, + sources: [], + completenessBefore: 0, + completenessAfter: 0, + filledFields: [], + warning: 'Tenant no encontrado', + }; + } + + const bbRes = await fetch( + `${base}/rest/v1/documents?tenant_id=eq.${encodeURIComponent( + tenant.id + )}&type=eq.${BRANDBOOK_DOC_TYPE}&select=id,content&order=updated_at.desc&limit=1`, + { headers, cache: 'no-store' } + ); + const bbRows = (await bbRes.json()) as { id: string; content: unknown }[]; + const existing = parseBrandbook(bbRows[0]?.content); + const created = !existing; + const sources: BrandEnrichSource[] = []; + const filledFields: string[] = []; + + const seed = seedBrandbook(tenant.slug, tenant.name); + sources.push('seed'); + + let book: Brandbook = existing + ? mergeFillEmpty(existing, seed, filledFields) + : seed; + + if (opts.forceSeed && existing) { + book = mergeFillEmpty(existing, seed, filledFields); + } + + const before = brandbookCompleteness(book).score; + + const styleRaw = + tenant.creative_style && + typeof tenant.creative_style === 'object' && + Object.keys(tenant.creative_style as object).length > 0 + ? tenant.creative_style + : (tenant.settings as { creative_style?: unknown } | undefined) + ?.creative_style; + if (styleRaw && typeof styleRaw === 'object') { + const style = normalizeCreativeStyle( + styleRaw, + tenant.name, + tenant.slug + ); + book = mergeFillEmpty(book, styleToPartial(style), filledFields); + sources.push('creative_style'); + } + + const tenantPartial: Partial = { + brand_name: tenant.name, + website: tenant.default_landing_url || '', + freeform_notes: tenant.agency_notes || '', + }; + if (tenant.logo_url) { + tenantPartial.logo_usage = fillStr( + book.logo_usage, + `Logo portal: ${tenant.logo_url}` + ); + } + book = mergeFillEmpty(book, tenantPartial, filledFields); + sources.push('tenant'); + + // Mirror Drive integration → tenants.drive_folder_id (skip seasonal/navidad folders) + try { + const driveRes = await fetch( + `${base}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent( + tenant.id + )}&provider=eq.google_drive&status=eq.connected&select=external_account_id,label&limit=1`, + { headers, cache: 'no-store' } + ); + if (driveRes.ok) { + const drows = (await driveRes.json()) as { + external_account_id?: string; + label?: string; + }[]; + const d = drows[0]; + if ( + d?.external_account_id && + !/navidad/i.test(d.label || '') + ) { + await fetch(`${base}/rest/v1/tenants?id=eq.${tenant.id}`, { + method: 'PATCH', + headers: { + ...headers, + 'Content-Type': 'application/json', + Prefer: 'return=minimal', + }, + body: JSON.stringify({ drive_folder_id: d.external_account_id }), + }); + } + } + } catch { + /* ignore */ + } + + // Señales scrapeadas (colores/fuentes web) — solo rellena vacíos + const scraped = scrapedToPartial(tenant.slug); + if (scraped.colors?.length || scraped.fonts_kit?.length || scraped.website) { + book = mergeFillEmpty(book, scraped, filledFields); + if (!sources.includes('website')) sources.push('website'); + } + + // Website scrape: meta description / title → pitch, tagline, value prop + const site = + book.website || + tenant.default_landing_url || + scraped.website || + ''; + if (site) { + try { + const href = /^https?:\/\//i.test(site) ? site : `https://${site}`; + const wr = await fetch(href, { + cache: 'no-store', + headers: { 'User-Agent': '4VenturesBrandBot/1.0' }, + signal: AbortSignal.timeout(8000), + }); + if (wr.ok) { + const html = (await wr.text()).slice(0, 100000); + const title = + html.match(/]*>([^<]{8,160})<\/title>/i)?.[1]?.trim() || ''; + const desc = + html + .match( + /]+name=["']description["'][^>]+content=["']([^"']{20,400})["']/i + )?.[1] + ?.trim() || + html + .match( + /content=["']([^"']{20,400})["'][^>]+name=["']description["']/i + )?.[1] + ?.trim() || + ''; + const ogTitle = + html + .match( + /]+property=["']og:title["'][^>]+content=["']([^"']{8,160})["']/i + )?.[1] + ?.trim() || ''; + const partial: Partial = {}; + if (desc) { + partial.elevator_pitch = desc; + partial.value_proposition = desc.slice(0, 280); + } + if ((ogTitle || title) && isBlank(book.tagline)) { + const cleaned = (ogTitle || title) + .replace(/&#\d+;|&[a-z]+;/gi, ' ') + .replace(/\s*[|\-–—].*$/, '') + .replace(/\s+/g, ' ') + .trim() + .slice(0, 120); + const brand = (book.brand_name || tenant.name || '').toLowerCase(); + if ( + cleaned.length >= 12 && + cleaned.toLowerCase() !== brand && + !/^home$/i.test(cleaned) + ) { + partial.tagline = cleaned; + } + } + if (desc || title) { + book = mergeFillEmpty(book, partial, filledFields); + book = ingestSourceText( + book, + 'website.html', + [title && `Title: ${title}`, desc && `Description: ${desc}`] + .filter(Boolean) + .join('\n') + ); + if (!sources.includes('website')) sources.push('website'); + } + } + } catch { + /* ignore network/timeout */ + } + } + + const intRes = await fetch( + `${base}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent( + tenant.id + )}&status=eq.connected&select=provider,external_account_id,label,meta`, + { headers, cache: 'no-store' } + ); + const integrations = intRes.ok + ? ((await intRes.json()) as { + provider: string; + external_account_id?: string | null; + label?: string | null; + meta?: Record | null; + }[]) + : []; + + const handles: Brandbook['channel_handles'] = { + instagram: '', + facebook: '', + linkedin: '', + tiktok: '', + blog: '', + youtube: '', + }; + const channelRoles: Brandbook['channels'] = { + instagram: '', + facebook: '', + linkedin: '', + tiktok: '', + blog: '', + youtube: '', + email: '', + other: '', + }; + let gadsLabel = ''; + + for (const row of integrations) { + const p = row.provider; + const id = row.external_account_id || row.label || ''; + const metaUser = + typeof row.meta?.username === 'string' + ? row.meta.username + : typeof row.meta?.name === 'string' + ? row.meta.name + : ''; + if (p === 'instagram') { + handles.instagram = normalizeHandle(metaUser || id || row.label || '', 'instagram'); + channelRoles.instagram = 'Conectado · Community + Reels'; + } else if (p === 'facebook') { + handles.facebook = normalizeHandle(id || row.label || '', 'facebook'); + channelRoles.facebook = 'Conectado · Página'; + } else if (p === 'linkedin') { + handles.linkedin = normalizeHandle(id || row.label || '', 'linkedin'); + channelRoles.linkedin = 'Conectado · Company page'; + } else if (p === 'google_ads' || p === 'google-ads') { + gadsLabel = row.label || id; + } else if (p === 'tiktok') { + handles.tiktok = id || row.label || ''; + } + } + + if (Object.values(handles).some(Boolean) || gadsLabel) { + book = mergeFillEmpty( + book, + { + channel_handles: handles, + channels: channelRoles, + freeform_notes: gadsLabel ? `Google Ads: ${gadsLabel}` : '', + }, + filledFields + ); + sources.push('integrations'); + } + + const metaPartial = await fetchMetaProfile({ + pageId: tenant.meta_page_id, + igUserId: tenant.meta_ig_user_id, + }); + if ( + metaPartial.website || + metaPartial.elevator_pitch || + metaPartial.channel_handles?.instagram || + metaPartial.channel_handles?.facebook + ) { + book = mergeFillEmpty(book, metaPartial, filledFields); + if (metaPartial.freeform_notes) { + book = ingestSourceText(book, 'meta-graph.txt', metaPartial.freeform_notes); + } + sources.push('meta_graph'); + } + + // Copy real de Meta Ads / Google Ads / captions IG → brief creativo + try { + const adsBundle = await collectAdsInsightsForTenant(tenant.id); + if ( + adsBundle.stats.metaAds || + adsBundle.stats.googleAds || + adsBundle.stats.igCaptions || + adsBundle.stats.fbPosts || + adsBundle.stats.calendarSocial + ) { + book = applyAdsInsightsToBrandbook(book, adsBundle, { + overwriteAdsFields: true, + }); + if (adsBundle.sourceText) { + book = ingestSourceText( + book, + 'ads-insights-meta-google-social.txt', + adsBundle.sourceText + ); + } + const adsFilled = [ + 'winning_hooks', + 'example_good', + 'ads_ctas', + 'preferred_angles', + 'ads_usps', + 'linkedin_themes', + ].filter((k) => { + const v = (adsBundle.partial as Record)[k]; + return Array.isArray(v) && v.length > 0; + }); + for (const f of adsFilled) { + if (!filledFields.includes(f)) filledFields.push(f); + } + sources.push('ads_insights'); + } + } catch { + /* ads APIs opcionales */ + } + + const driveNotes = await driveBrandHints(tenant.id); + if (driveNotes) { + book = ingestSourceText(book, 'drive-brand-files.txt', driveNotes); + sources.push('drive'); + } + + const cal = await calendarSeriesHints(tenant.id); + if (cal.series.length || cal.pillars.length) { + book = mergeFillEmpty( + book, + { + series_recurrentes: cal.series, + pillars_rrss: cal.pillars, + }, + filledFields + ); + sources.push('calendar'); + } + + // Alias note for Superhuman / Drive matching (helps operators find docs) + sources.push('superhuman_alias'); + + book.brand_name = book.brand_name || tenant.name; + book.updated_at = new Date().toISOString(); + + const after = brandbookCompleteness(book).score; + + let warning: string | undefined; + if (!opts.dryRun) { + const jsonHeaders = { + ...headers, + 'Content-Type': 'application/json', + Prefer: 'return=minimal', + }; + if (bbRows[0]?.id) { + const save = await fetch( + `${base}/rest/v1/documents?id=eq.${bbRows[0].id}`, + { + method: 'PATCH', + headers: jsonHeaders, + body: JSON.stringify({ + content: book, + title: BRANDBOOK_DOC_TITLE, + updated_at: new Date().toISOString(), + }), + } + ); + if (!save.ok) { + warning = `PATCH brandbook: ${(await save.text()).slice(0, 200)}`; + } + } else { + const save = await fetch(`${base}/rest/v1/documents`, { + method: 'POST', + headers: jsonHeaders, + body: JSON.stringify({ + tenant_id: tenant.id, + type: BRANDBOOK_DOC_TYPE, + title: BRANDBOOK_DOC_TITLE, + status: 'approved', + content: book, + }), + }); + if (!save.ok) { + warning = `POST brandbook: ${(await save.text()).slice(0, 200)}`; + } + } + } + + return { + ok: !warning, + tenantId: tenant.id, + slug: tenant.slug, + created, + sources, + completenessBefore: before, + completenessAfter: after, + filledFields, + warning, + }; +} + +export async function enrichAllTenantBrandbooks(opts?: { + forceSeed?: boolean; + dryRun?: boolean; + slugFilter?: string; +}): Promise { + const headers = serviceHeaders(); + const base = supabaseUrl(); + if (!headers || !base) return []; + + const tRes = await fetch( + `${base}/rest/v1/tenants?agency_followed=neq.false&select=id,slug,name&order=name.asc`, + { headers, cache: 'no-store' } + ); + let tenants = (await tRes.json()) as { id: string; slug: string; name: string }[]; + if (opts?.slugFilter) { + const f = opts.slugFilter.toLowerCase(); + tenants = tenants.filter( + (t) => t.slug === f || t.name.toLowerCase().includes(f) + ); + } + + const out: BrandEnrichResult[] = []; + for (const t of tenants) { + out.push( + await enrichTenantBrandbook({ + tenantId: t.id, + forceSeed: opts?.forceSeed, + dryRun: opts?.dryRun, + }) + ); + } + return out; +} diff --git a/src/lib/brandbook.ts b/src/lib/brandbook.ts index a192d9b..a7a02fd 100644 --- a/src/lib/brandbook.ts +++ b/src/lib/brandbook.ts @@ -4,6 +4,10 @@ */ import { brandBrief } from '@/lib/calendar-generate-brand'; +import { + brandSiblingNote, + resolveBrandSlug, +} from '@/lib/tenant-brand-aliases'; export const BRANDBOOK_DOC_TYPE = 'brandbook'; export const BRANDBOOK_DOC_TITLE = 'Brandbook'; @@ -807,40 +811,205 @@ export function seedBrandbook(slug: string, label: string): Brandbook { blog: 'SEO certificaciones y objeciones', }, }, - 'talento-y-deporte': { + tyd: { vertical: brief.vertical, tone: brief.tone, elevator_pitch: - 'Becas deportivas y académicas en USA con acompañamiento end-to-end.', - brand_promise: 'Tu talento abre puertas; nosotros abrimos el camino.', - positioning: 'Deporte + estudios en USA con proceso guiado', + 'Becas deportivas en USA con acompañamiento end-to-end para atletas-estudiantes.', + brand_promise: 'Tu talento deportivo abre puertas; nosotros abrimos el camino.', + positioning: 'TYD · Becas deportivas USA con proceso guiado', + mission: + 'Ayudar a atletas-estudiantes a conseguir becas deportivas reales en universidades de EE.UU. con proceso guiado.', + tagline: 'Tu talento, tu campus en USA', + icp: 'Atletas-estudiantes (y familias) en España/LATAM que buscan beca deportiva + estudios en EE.UU.', archetype: 'El Héroe + El Mentor', + website: 'talentoydeporte.com', pillars_rrss: brief.pillars, series_recurrentes: [ 'Nuevo alumno', - 'Beca académica', 'Beca deportiva', 'Campus USA', 'Objeciones', 'English tips', + 'Vida deportiva', ], products_services: [ 'Becas deportivas USA', - 'Acompañamiento académico', - 'Campus visits', + 'Recruiting deportivo', + 'Acompañamiento visa/campus', ], ads_usps: [ - 'Becas reales', + 'Becas deportivas reales', 'Acompañamiento end-to-end', - 'Historias de alumnos', + 'Historias de atletas', ], - ads_ctas: ['Agenda llamada', 'Evalúa tu perfil'], + ads_ctas: ['Agenda llamada', 'Evalúa tu perfil deportivo'], linkedin_themes: ['Educación internacional', 'Deporte + estudios'], - blog_themes: ['Guía becas USA', 'Requisitos TOEFL/SAT', 'Vida en campus'], + blog_themes: ['Guía becas deportivas USA', 'Requisitos', 'Vida en campus'], claims_forbidden: [ 'Garantizar beca al 100%', 'Promesas de universidades sin matiz', + 'Usar creatividades o claims de TSP Scholar sin matiz deportivo', ], + freeform_notes: brandSiblingNote('tyd') || '', + channels: { + ...base.channels, + instagram: 'Canal principal · historias de atletas', + facebook: 'Comunidad padres / familias', + }, + channel_handles: { + ...base.channel_handles, + instagram: 'https://instagram.com/talentoydeporte', + }, + }, + tsp: { + vertical: brief.vertical, + tone: brief.tone, + elevator_pitch: + 'Programa Scholar de becas académicas en USA con orientación de admisiones y financiación.', + brand_promise: 'Tu expediente también puede abrirte el campus americano.', + positioning: 'TSP · Becas académicas USA (marca hermana de TYD, no mezclar)', + mission: + 'Orientar a estudiantes con buen expediente hacia becas académicas y admisiones en EE.UU.', + tagline: 'Tu expediente, tu campus en USA', + icp: 'Estudiantes (y familias) con perfil académico sólido que buscan universidad en EE.UU. sin eje deportivo.', + archetype: 'El Sabio + El Mentor', + website: 'talentoydeporte.com', + pillars_rrss: brief.pillars, + series_recurrentes: [ + 'Beca académica', + 'Admisiones', + 'Financiación', + 'Campus USA', + 'Casos de éxito', + 'Objeciones', + ], + products_services: [ + 'Becas académicas USA', + 'Orientación de admisiones', + 'Plan de financiación', + ], + ads_usps: [ + 'Foco académico Scholar', + 'Admisiones guiadas', + 'Financiación clara', + ], + ads_ctas: ['Evalúa tu perfil académico', 'Agenda orientación'], + linkedin_themes: ['Educación internacional', 'Admisiones USA'], + blog_themes: ['SAT/TOEFL', 'Financiación becas', 'Vida académica en campus'], + claims_forbidden: [ + 'Garantizar admisión o beca al 100%', + 'Usar creatividades o claims deportivos de TYD sin matiz Scholar', + ], + freeform_notes: brandSiblingNote('tsp') || '', + }, + dtatf: { + vertical: brief.vertical, + tone: brief.tone, + elevator_pitch: + 'Formación profesional cercana orientada a empleabilidad y cambio de vida laboral.', + brand_promise: 'Formarte de tú a tú para trabajar con seguridad.', + positioning: 'DTATF · Formación práctica (marca distinta de ECP)', + mission: + 'Acercar formación profesional práctica y acreditaciones con salida laboral real.', + icp: 'Adultos en reconversión o mejora laboral que buscan cursos/acreditaciones con empleabilidad (p. ej. docencia FP).', + archetype: 'El Cuidador + El Ciudadano', + pillars_rrss: brief.pillars, + series_recurrentes: [ + 'Convocatorias', + 'Empleabilidad', + 'Testimonios', + 'Tips formación', + ], + products_services: ['Cursos de formación', 'Orientación laboral'], + ads_usps: ['Cercanía', 'Empleabilidad', 'Convocatorias claras'], + ads_ctas: ['Pedir info', 'Reservar plaza'], + claims_forbidden: [ + 'Usar marca visual o claims de ECP (cuidadores) sin matiz DTATF', + ], + freeform_notes: brandSiblingNote('dtatf') || '', + channels: { + ...base.channels, + instagram: 'Alcance y comunidad alumnos', + facebook: 'Convocatorias y familias', + }, + }, + ecp: { + vertical: brief.vertical, + tone: brief.tone, + elevator_pitch: + 'Escuela de Cuidadores Profesionales: formación humana y empleable para el sector cuidados.', + brand_promise: 'Cuidar con vocación y con método.', + positioning: 'ECP · Referente en formación de cuidadores (marca distinta de DTATF)', + mission: + 'Formar cuidadores profesionales con método, humanidad y conexión real con empleo.', + icp: 'Personas que quieren trabajar en cuidados (domicilio/residencias) y empresas que buscan perfiles formados.', + archetype: 'El Cuidador', + website: 'escueladecuidadoresprofesionales.com', + pillars_rrss: brief.pillars, + series_recurrentes: [ + 'Día a día del cuidador', + 'Formación', + 'Empleabilidad', + 'Testimonios', + 'Convocatorias', + ], + products_services: [ + 'Cursos de cuidador profesional', + 'Prácticas / empleabilidad', + ], + ads_usps: [ + 'Formación especializada', + 'Salida laboral', + 'Comunidad ECP', + ], + ads_ctas: ['Infórmate', 'Reserva tu plaza'], + claims_forbidden: [ + 'Usar marca visual o claims de DTATF sin matiz ECP/cuidadores', + ], + freeform_notes: brandSiblingNote('ecp') || '', + channels: { + ...base.channels, + instagram: 'Canal principal @escuelaecp', + facebook: 'Comunidad y convocatorias', + }, + channel_handles: { + ...base.channel_handles, + instagram: 'https://instagram.com/escuelaecp', + blog: 'https://escueladecuidadoresprofesionales.com', + }, + }, + 'agua-miia': { + vertical: brief.vertical, + tone: brief.tone, + elevator_pitch: + 'Agua mineral de origen natural para hidratación diaria con identidad fresca y premium accesible.', + brand_promise: 'Hidratación limpia, natural y con carácter.', + positioning: 'Agua con personalidad: fresca, cercana y visualmente clara', + archetype: 'El Inocente + El Explorador', + pillars_rrss: brief.pillars, + series_recurrentes: [ + 'Producto hero', + 'Ritual diario', + 'Origen', + 'Estilo de vida', + 'Sostenibilidad', + ], + products_services: ['Agua mineral', 'Formatos retail', 'Horeca'], + ads_usps: ['Origen natural', 'Sabor limpio', 'Formato para el día a día'], + ads_ctas: ['Descúbrela', 'Encuéntrala', 'Pruébala'], + photography_style: + 'Producto hero con luz natural, escenas de hidratación y lifestyle limpio; mucho aire y tipografía clara.', + claims_forbidden: [ + 'Claims médicos o curativos sin respaldo', + 'Comparativas agresivas con competencia nombrada', + ], + channels: { + ...base.channels, + instagram: 'Canal principal · producto + lifestyle', + facebook: 'Alcance y comunidad local', + tiktok: 'Reels cortos de ritual / producto', + }, }, 'halal-safety': { vertical: 'Certificación Halal y Consultoría de Exportación Agroalimentaria', @@ -1482,7 +1651,8 @@ export function seedBrandbook(slug: string, label: string): Brandbook { }, }; - const resolvedSlug = slug === 'blue-parrot-school' ? 'bps' : slug; + const resolvedSlug = + slug === 'blue-parrot-school' ? 'bps' : resolveBrandSlug(slug); const extra = seeds[resolvedSlug] || { vertical: brief.vertical, tone: brief.tone, @@ -1517,16 +1687,33 @@ export function seedBrandbook(slug: string, label: string): Brandbook { export function parseBrandbook(raw: unknown): Brandbook | null { if (!raw) return null; - let o: any = raw; - if (typeof raw === 'string') { + // documents.content a menudo llega como string JSON (columna text / doble encode) + let data: unknown = raw; + if (typeof data === 'string') { + const t = data.trim(); + if (!t) return null; try { - o = JSON.parse(raw); + data = JSON.parse(t); } catch { return null; } + if (typeof data === 'string') { + try { + data = JSON.parse(data); + } catch { + return null; + } + } } - if (!o || typeof o !== 'object') return null; - if (o.content && (typeof o.content === 'object' || typeof o.content === 'string')) { + if (!data || typeof data !== 'object') return null; + const o = data as Record; + // wrapper { content: brandbook } + if ( + o.content && + (typeof o.content === 'object' || typeof o.content === 'string') && + !o.brand_name && + !('tone' in o) + ) { return parseBrandbook(o.content); } const ver = Number(o.version); @@ -1548,11 +1735,46 @@ export function parseBrandbook(raw: unknown): Brandbook | null { const empty = emptyBrandbook(String(partial.brand_name || '')); const colors = migrateColors(partial, empty); const fonts_kit = migrateFonts(partial, empty); + const str = (v: unknown, fallback = ''): string => { + if (v == null) return fallback; + return String(v); + }; return { ...empty, ...partial, version: 2, + brand_name: str(partial.brand_name, empty.brand_name), + tagline: str(partial.tagline), + elevator_pitch: str(partial.elevator_pitch), + brand_promise: str(partial.brand_promise), + positioning: str(partial.positioning), + archetype: str(partial.archetype), + origin_story: str(partial.origin_story), + mission: str(partial.mission), + vision: str(partial.vision), + vertical: str(partial.vertical), + tone: str(partial.tone), + visual_notes: str(partial.visual_notes), + logo_usage: str(partial.logo_usage), + photography_style: str(partial.photography_style), + illustration_style: str(partial.illustration_style), + icon_style: str(partial.icon_style), + icp: str(partial.icp), + geo: str(partial.geo), + customer_journey: str(partial.customer_journey), + value_proposition: str(partial.value_proposition), + guarantee: str(partial.guarantee), + pricing_notes: str(partial.pricing_notes), + content_matrix: str(partial.content_matrix), + channel_cadence: str(partial.channel_cadence), + landing_notes: str(partial.landing_notes), + abm_notes: str(partial.abm_notes), + blog_cadence: str(partial.blog_cadence), + legal_notes: str(partial.legal_notes), + sector_rules: str(partial.sector_rules), + image_rights: str(partial.image_rights), + freeform_notes: str(partial.freeform_notes), values: arr(partial.values), personality: arr(partial.personality), voice_do: arr(partial.voice_do), @@ -1776,6 +1998,12 @@ export function brandbookToPrompt(book: Brandbook): string { book.icp && `ICP: ${book.icp}`, book.ads_usps.length && `USPs ads: ${book.ads_usps.join('; ')}`, book.ads_ctas.length && `CTAs: ${book.ads_ctas.join('; ')}`, + book.winning_hooks.length && + `Hooks ads/RRSS observados: ${book.winning_hooks.slice(0, 10).join(' | ')}`, + book.example_good.length && + `Ejemplos copy real (ads/IG/FB/LI): ${book.example_good.slice(0, 6).join(' | ')}`, + book.preferred_angles.length && + `Ángulos campaña: ${book.preferred_angles.slice(0, 8).join(' · ')}`, book.linkedin_themes.length && `LinkedIn: ${book.linkedin_themes.join(', ')}`, book.blog_themes.length && `Blog: ${book.blog_themes.join(', ')}`, @@ -1792,30 +2020,40 @@ export function brandbookCompleteness(book: Brandbook): { score: number; missing: string[]; } { + const s = (v: unknown) => String(v || '').trim(); + const n = (v: unknown) => (Array.isArray(v) ? v.length : 0); const checks: [string, boolean][] = [ [ 'Identidad (pitch/misión/posicionamiento)', - !!(book.elevator_pitch || book.mission || book.positioning), + !!(s(book.elevator_pitch) || s(book.mission) || s(book.positioning)), ], - ['Voz/tono', !!book.tone], + ['Voz/tono', !!s(book.tone)], [ 'Visual (color o logo)', - !!(book.colors.length || book.kit_assets.some((a) => a.kind === 'logo')), + !!(n(book.colors) || (book.kit_assets || []).some((a) => a.kind === 'logo')), + ], + ['Tipografía', n(book.fonts_kit) > 0], + [ + 'Audiencia/ICP', + !!( + s(book.icp) || + n(book.personas) || + n(book.b2b_personas) || + n(book.b2c_personas) + ), ], - ['Tipografía', book.fonts_kit.length > 0], - ['Audiencia/ICP', !!(book.icp || book.personas.length)], [ 'Oferta', - !!(book.products_services.length || book.value_proposition), + !!(n(book.products_services) || s(book.value_proposition)), ], [ 'Pilares/series RRSS', - !!(book.pillars_rrss.length || book.series_recurrentes.length), + !!(n(book.pillars_rrss) || n(book.series_recurrentes)), ], - ['Ads (USP o CTA)', !!(book.ads_usps.length || book.ads_ctas.length)], + ['Ads (USP o CTA)', !!(n(book.ads_usps) || n(book.ads_ctas))], [ 'LinkedIn B2B o blog', - !!(book.linkedin_themes.length || book.blog_themes.length), + !!(n(book.linkedin_themes) || n(book.blog_themes)), ], ]; const missing = checks.filter(([, ok]) => !ok).map(([l]) => l); @@ -2158,19 +2396,66 @@ export function compileClientSkillFromBrandbook( lines.push(fallbackAud); lines.push(''); - // 8. Ejemplos de hooks + // 8. Copy real de Meta / Google / IG / FB / LinkedIn + const adsExcerpt = (book.source_excerpts || []).find((e) => + /ads-insights|meta-graph|COPY REAL DESDE ADS/i.test( + `${e.filename || ''} ${e.excerpt || ''}` + ) + ); const hooks = book.winning_hooks && book.winning_hooks.length > 0 ? book.winning_hooks : book.slogan_variants; if (hooks && hooks.length > 0) { lines.push( - '## Ejemplos de hooks que han funcionado (referencia de registro, no para copiar)' + '## Copy real de campañas y redes (Meta Ads / Google Ads / IG / FB / LinkedIn)' ); - for (const h of hooks) { + lines.push(''); + lines.push( + '> Extraído de anuncios, captions IG, posts FB y piezas LinkedIn del calendario. Registro de qué ya se ha dicho — **no copiar literal**; variar ángulo y prueba.' + ); + lines.push(''); + lines.push('### Hooks / primary texts / titulares observados'); + for (const h of hooks.slice(0, 16)) { lines.push(`- "${h}"`); } lines.push(''); + if (book.example_good && book.example_good.length > 0) { + lines.push('### Ejemplos de copy observado (cuerpo / captions)'); + for (const ex of book.example_good.slice(0, 8)) { + lines.push(`- "${ex}"`); + } + lines.push(''); + } + if (book.ads_ctas && book.ads_ctas.length > 0) { + lines.push( + `### CTAs observados en Meta: ${book.ads_ctas.join(', ')}` + ); + lines.push(''); + } + const campAngles = + book.preferred_angles && book.preferred_angles.length > 0 + ? book.preferred_angles + : book.ads_angles; + if (campAngles && campAngles.length > 0) { + lines.push( + `### Ángulos de campaña observados: ${campAngles.slice(0, 12).join(' · ')}` + ); + lines.push(''); + } + if (book.linkedin_themes && book.linkedin_themes.length > 0) { + lines.push( + `### Temas LinkedIn observados: ${book.linkedin_themes.join(' · ')}` + ); + lines.push(''); + } + } else if (adsExcerpt?.excerpt) { + lines.push( + '## Copy real de campañas y redes (Meta Ads / Google Ads / IG / FB / LinkedIn)' + ); + lines.push(''); + lines.push(adsExcerpt.excerpt.slice(0, 2500)); + lines.push(''); } const extra = diff --git a/src/lib/canva-templates.ts b/src/lib/canva-templates.ts new file mode 100644 index 0000000..11f329d --- /dev/null +++ b/src/lib/canva-templates.ts @@ -0,0 +1,119 @@ +/** + * Canva Brand Templates per tenant. + * + * Recommended structure (1 Brand Kit + 2 template groups): + * canva_templates: { + * ads: { "foto-con-franja", "tipografico", "dato-grande", "stories_9_16", "landscape_1_91", "default" }, + * rrss: { "foto-con-franja", "tipografico", "dato-grande", "stories_9_16", "default" }, + * // flat legacy keys still work as fallback for both + * } + * + * Do NOT create two Canva "Brand Kits" per client — share logos/colors/fonts. + * Do create separate Brand Templates (or a folder of templates) for Ads vs RRSS layouts. + */ + +export type CanvaTemplateMap = Record; + +export type CanvaTemplatesConfig = { + ads?: CanvaTemplateMap; + rrss?: CanvaTemplateMap; +} & { + [key: string]: string | CanvaTemplateMap | undefined; +}; + +const ADS_KEYS = [ + 'foto-con-franja', + 'tipografico', + 'dato-grande', + 'stories_9_16', + 'landscape_1_91', + 'default', +] as const; + +const RRSS_KEYS = [ + 'foto-con-franja', + 'tipografico', + 'dato-grande', + 'stories_9_16', + 'default', +] as const; + +function asMap(v: unknown): CanvaTemplateMap { + if (!v || typeof v !== 'object' || Array.isArray(v)) return {}; + const out: CanvaTemplateMap = {}; + for (const [k, val] of Object.entries(v as Record)) { + if (k === 'ads' || k === 'rrss') continue; + if (typeof val === 'string' && val.trim()) out[k] = val.trim(); + } + return out; +} + +export function normalizeCanvaTemplates(raw: unknown): CanvaTemplatesConfig { + if (!raw || typeof raw !== 'object') return {}; + const obj = raw as Record; + const flat = asMap(obj); + const ads = { ...flat, ...asMap(obj.ads) }; + const rrss = { ...flat, ...asMap(obj.rrss) }; + return { ...flat, ads, rrss }; +} + +/** Resolve templates for n8n / WF5 for a given channel. */ +export function resolveCanvaTemplatesForChannel( + raw: unknown, + channel: 'ads' | 'rrss' +): CanvaTemplateMap { + const cfg = normalizeCanvaTemplates(raw); + const channelMap = channel === 'ads' ? cfg.ads || {} : cfg.rrss || {}; + const keys = channel === 'ads' ? ADS_KEYS : RRSS_KEYS; + const out: CanvaTemplateMap = {}; + for (const k of keys) { + const fromChannel = channelMap[k]; + const fromFlat = cfg[k]; + const v = + (typeof fromChannel === 'string' && fromChannel) || + (typeof fromFlat === 'string' && fromFlat) || + ''; + if (v) out[k] = v; + } + // pass through any extra keys on the channel map + for (const [k, v] of Object.entries(channelMap)) { + if (typeof v === 'string' && v && !out[k]) out[k] = v; + } + return out; +} + +export function canvaTemplateKeyForLayout( + layout: string | null | undefined, + channel: 'ads' | 'rrss' +): string { + const l = String(layout || '').toLowerCase(); + if (l.includes('tipograf')) return 'tipografico'; + if (l.includes('dato')) return 'dato-grande'; + if (channel === 'rrss' && (l.includes('stor') || l.includes('reel'))) { + return 'stories_9_16'; + } + return 'foto-con-franja'; +} + +/** + * Resolve canva_templates for n8n payloads. + * Prefer nested ads/rrss; fall back to flat keys; BPS gets env default. + */ +export function resolveTenantCanvaTemplates( + raw: unknown, + opts?: { slug?: string | null; channel?: 'ads' | 'rrss' } +): CanvaTemplateMap { + const channel = opts?.channel || 'ads'; + let map = resolveCanvaTemplatesForChannel(raw, channel); + if ( + opts?.slug === 'bps' && + (!map || Object.keys(map).length === 0) + ) { + const defaultBps = process.env.BPS_CANVA_TEMPLATE_ID || 'EAHRb38v_WY'; + map = { + 'foto-con-franja': defaultBps, + default: defaultBps, + }; + } + return map; +} diff --git a/src/lib/creatives-drive.ts b/src/lib/creatives-drive.ts new file mode 100644 index 0000000..636b869 --- /dev/null +++ b/src/lib/creatives-drive.ts @@ -0,0 +1,433 @@ +/** + * Drive storage for generated creatives (Ads + RRSS). + * + * {carpeta-cliente}/ + * Creatividades-Ads_{slug}/ + * YYYY/MM/ + * {slug}_ads_{formato}_{YYYY-MM-DD}_v{N}[.slide-K].ext + * Creatividades-RRSS_{slug}/ + * YYYY/MM/ + * {slug}_rrss_{formato}_{YYYY-MM-DD}_v{N}[.slide-K].ext + * + * Legacy RRSS root `Pipeline-creatividades_{slug}` is still accepted for reads/visibility. + */ +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'; + +export type CreativeDriveChannel = 'ads' | 'rrss'; + +export type CreativeDriveItem = { + id: string; + title?: string | null; + format?: string | null; + caption?: string | null; + notes?: string | null; + scheduled_at?: string | null; + created_at?: string | null; + angle?: string | null; + hook?: string | null; +}; + +function pad2(n: number): string { + return String(n).padStart(2, '0'); +} + +export function pieceLocalDate(item: CreativeDriveItem): 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 creativesDriveRootFolderName( + channel: CreativeDriveChannel, + clientSlug: string +): string { + const name = slugify(clientSlug) || 'cliente'; + return channel === 'ads' + ? `Creatividades-Ads_${name}` + : `Creatividades-RRSS_${name}`; +} + +/** Legacy RRSS folder name (pre-split). */ +export function legacyRrssPipelineFolderName(clientSlug: string): string { + const name = slugify(clientSlug) || 'cliente'; + return `Pipeline-creatividades_${name}`; +} + +export function isoDateStamp(d: Date): string { + return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`; +} + +/** + * Concise Drive filename: + * {slug}_{ads|rrss}_{formato}_{YYYY-MM-DD}_v{N}[.slide-K].{ext} + */ +export function buildCreativeDriveFileName(opts: { + clientSlug: string; + channel: CreativeDriveChannel; + format: string; + date: Date; + version: number; + ext: string; + slideNumber?: number; +}): string { + const slug = slugify(opts.clientSlug) || 'cliente'; + const format = slugify(opts.format || 'pieza') || 'pieza'; + const date = isoDateStamp(opts.date); + const ver = Math.max(1, opts.version || 1); + const ext = (opts.ext || 'jpg').replace(/^\./, '').toLowerCase(); + let base = `${slug}_${opts.channel}_${format}_${date}_v${ver}`; + if (opts.slideNumber && opts.slideNumber > 0) { + base += `.slide-${opts.slideNumber}`; + } + return `${base}.${ext}`; +} + +/** Match existing versions for same slug/channel/format/date stem. */ +export function nextVersionFromExistingNames( + fileNames: string[], + opts: { + clientSlug: string; + channel: CreativeDriveChannel; + format: string; + date: Date; + } +): number { + const slug = slugify(opts.clientSlug) || 'cliente'; + const format = slugify(opts.format || 'pieza') || 'pieza'; + const date = isoDateStamp(opts.date); + const prefix = `${slug}_${opts.channel}_${format}_${date}_v`; + let max = 0; + for (const name of fileNames) { + if (!name.startsWith(prefix)) continue; + const m = name.slice(prefix.length).match(/^(\d+)/); + if (m) max = Math.max(max, Number(m[1])); + } + return max + 1; +} + +async function replaceDriveFileContent( + fileId: string, + mimeType: string, + buffer: Buffer +): Promise { + 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', + }; +} + +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, + }; +} + +export async function syncCreativeAssetToDrive(opts: { + tenantId: string; + clientLabel: string; + channel: CreativeDriveChannel; + item: CreativeDriveItem; + buffer: Buffer; + mimeType: string; + ext: string; + slot?: 'cover' | 'slide' | 'asset'; + 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 slug = tenant?.slug || opts.clientLabel || 'cliente'; + const rootName = creativesDriveRootFolderName(opts.channel, slug); + const d = pieceLocalDate(opts.item); + const year = String(d.getFullYear()); + const month = pad2(d.getMonth() + 1); + const format = opts.item.format || 'pieza'; + + const rootId = await ensureChildFolder(linked.folderId, rootName); + const yearId = await ensureChildFolder(rootId, year); + const monthId = await ensureChildFolder(yearId, month); + + const existing = await listFilesInFolder(monthId); + const version = nextVersionFromExistingNames( + existing.map((f) => f.name), + { + clientSlug: slug, + channel: opts.channel, + format, + date: d, + } + ); + + let slideNumber: number | undefined; + if ((format || '').toLowerCase() === 'carrusel') { + const slot = opts.slot || 'cover'; + const idx = opts.slideIndex || 0; + slideNumber = slot === 'cover' ? 1 : Math.max(2, idx + 2); + } + + const fileName = buildCreativeDriveFileName({ + clientSlug: slug, + channel: opts.channel, + format, + date: d, + version, + 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', + }; + } +} + +/** Ensure both Ads + RRSS creatives folders exist under the client root. */ +export async function ensureCreativesDriveFolders( + tenantId: string, + clientLabel: string +): Promise<{ + ok: boolean; + ads?: string; + rrss?: string; + warning?: string; +}> { + if (!driveConfigured()) { + return { ok: false, warning: 'Drive no configurado' }; + } + const linked = await resolveOrLinkTenantDriveFolder(tenantId, clientLabel); + if (!linked?.folderId) { + return { ok: false, warning: 'Sin carpeta cliente en Drive' }; + } + const tenant = await loadTenant(tenantId); + const slug = tenant?.slug || clientLabel; + const adsName = creativesDriveRootFolderName('ads', slug); + const rrssName = creativesDriveRootFolderName('rrss', slug); + const adsId = await ensureChildFolder(linked.folderId, adsName); + const rrssId = await ensureChildFolder(linked.folderId, rrssName); + return { ok: true, ads: adsId, rrss: rrssId }; +} + +export async function backfillCreativeItemToDrive(opts: { + tenantId: string; + clientLabel: string; + channel: CreativeDriveChannel; + item: CreativeDriveItem & { + cover_path?: string | null; + slide_paths?: unknown; + storage_path?: string | null; + }; +}): 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' | 'asset'; index: number }[] = + []; + if (opts.channel === 'ads' && opts.item.storage_path) { + jobs.push({ path: opts.item.storage_path, slot: 'asset', index: 0 }); + } + if (opts.item.cover_path && !/^https?:\/\//i.test(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 && !/^https?:\/\//i.test(p)) { + jobs.push({ path: p, slot: 'slide', index: i }); + } + }); + + 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 syncCreativeAssetToDrive({ + tenantId: opts.tenantId, + clientLabel: opts.clientLabel, + channel: opts.channel, + 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 }; +} diff --git a/src/lib/tenant-brand-aliases.ts b/src/lib/tenant-brand-aliases.ts new file mode 100644 index 0000000..b17ac12 --- /dev/null +++ b/src/lib/tenant-brand-aliases.ts @@ -0,0 +1,66 @@ +/** + * Canonical brand codes for portfolio clients that used to be merged. + * + * tyd = Talento y Deporte (deportivo) + * tsp = Talento Scholar Program (académico) + * ecp = Escuela de Cuidadores Profesionales + * dtatf = De Tú a Tú Formación + */ +export const BRAND_CANONICAL_SLUGS = { + tyd: 'tyd', + tsp: 'tsp', + ecp: 'ecp', + dtatf: 'dtatf', +} as const; + +export type BrandCanonicalSlug = + (typeof BRAND_CANONICAL_SLUGS)[keyof typeof BRAND_CANONICAL_SLUGS]; + +/** Legacy → canonical (and identity). */ +const SLUG_ALIASES: Record = { + tyd: 'tyd', + 'talento-y-deporte': 'tyd', + talentoydeporte: 'tyd', + tsp: 'tsp', + 'talento-scholar-program': 'tsp', + 'talento-scholar': 'tsp', + scholar: 'tsp', + ecp: 'ecp', + 'escuela-de-cuidadores': 'ecp', + 'escuela-cuidadores': 'ecp', + dtatf: 'dtatf', + 'de-tu-a-tu-formacion': 'dtatf', + 'de-tu-a-tu': 'dtatf', + detuatu: 'dtatf', + detuatuformacion: 'dtatf', +}; + +export const BRAND_DISPLAY_NAMES: Record = { + tyd: 'Talento y Deporte', + tsp: 'Talento Scholar Program', + ecp: 'ECP · Escuela de Cuidadores Profesionales', + dtatf: 'De Tú a Tú Formación', +}; + +export const BRAND_SIBLINGS: Record = { + tyd: 'tsp', + tsp: 'tyd', + ecp: 'dtatf', + dtatf: 'ecp', +}; + +/** Resolve any legacy/canonical slug to the brand DNA key. */ +export function resolveBrandSlug(slug: string | null | undefined): string { + const s = String(slug || '') + .toLowerCase() + .trim(); + if (!s) return s; + return SLUG_ALIASES[s] || s; +} + +export function brandSiblingNote(slug: string): string | null { + const canon = resolveBrandSlug(slug); + if (!(canon in BRAND_SIBLINGS)) return null; + const sib = BRAND_SIBLINGS[canon as BrandCanonicalSlug]; + return `Marca hermana (separada): ${BRAND_DISPLAY_NAMES[sib]} (${sib}). No mezclar creatividades, Meta ni Drive.`; +}