From 4a1abd3318759d961ec0e0af0c9c461c60eeca5e Mon Sep 17 00:00:00 2001 From: 4Ventures <4ventures@MacBook-Air-de-4Ventures.local> Date: Fri, 16 Oct 2026 04:15:51 +0200 Subject: [PATCH] feat: LinkedIn editorial, accesos agencia estables y deshacer en historial Integra estrategias LinkedIn en calendarios (backlog publicado vs pendiente), unifica roles/cookies de vista agencia y permite deshacer cambios de brandbook dejando constancia en el historial. Co-authored-by: Cursor --- scripts/audit_all_brands.ts | 401 ++++ scripts/audit_four_brands.ts | 82 + scripts/complete_brandbooks.py | 1711 +++++++++++++++++ scripts/enrich_thin_dna.ts | 46 + scripts/ensure_ecp_tsp_drive.ts | 132 ++ scripts/fix_brand_gaps.ts | 232 +++ scripts/heal_agency_roles.ts | 96 + scripts/import_drive_calendars_batch.ts | 2 +- scripts/import_drive_calendars_live.py | 2 +- scripts/remediate_brand_truth.py | 632 ++++++ scripts/rename_brand_slugs.ts | 135 ++ scripts/split_tenants_and_fix.ts | 272 +++ src/app/api/admin/create-client/route.ts | 4 +- src/app/api/assets/drive/route.ts | 2 +- src/app/api/brandbook/route.ts | 16 +- .../api/calendar/linkedin-strategy/route.ts | 116 ++ src/app/api/calendar/preview/route.ts | 70 +- src/app/api/change-history/undo/route.ts | 262 +++ .../api/creative-pipeline/generate/route.ts | 9 +- src/app/api/creative-pipeline/route.ts | 9 +- src/app/api/creative-pipeline/upload/route.ts | 2 +- src/app/api/creatives/upload/route.ts | 66 +- .../integrations/creative-pipeline/route.ts | 42 +- .../api/integrations/creatives/[id]/route.ts | 93 +- src/app/api/integrations/creatives/route.ts | 34 +- src/app/auth/actions.ts | 17 + src/app/dashboard/agencia/page.tsx | 4 +- src/app/dashboard/layout.tsx | 27 +- src/app/login/page.tsx | 13 +- src/components/AssetsLibrary.tsx | 2 +- src/components/CalendarCreator.tsx | 311 ++- src/components/ChangeHistoryView.tsx | 106 +- src/components/CreativePipelineBoard.tsx | 16 + src/components/DashboardShell.tsx | 29 +- src/components/RrssMonthView.tsx | 43 + src/lib/agency.ts | 61 +- src/lib/assets-client-scope.ts | 4 +- src/lib/brandbook.ts | 15 +- src/lib/calendar-gen-options.ts | 32 + src/lib/calendar-generate-brand.ts | 133 +- src/lib/calendar-generate.ts | 111 +- src/lib/calendar-strategy.ts | 351 +++- src/lib/change-history.ts | 51 + src/lib/creatives-workflow.ts | 17 +- src/lib/google-drive.ts | 47 +- src/lib/linkedin-strategy.ts | 497 +++++ src/lib/match.ts | 43 +- src/lib/meta-publish.ts | 75 +- src/lib/pipeline-drive.ts | 347 +--- src/lib/pipeline-publish-worker.ts | 22 + src/lib/pipeline-schedule.ts | 12 +- src/lib/rrss-generation.ts | 3 + src/lib/superhuman-docs.ts | 33 +- src/middleware.ts | 37 +- 54 files changed, 6400 insertions(+), 527 deletions(-) create mode 100644 scripts/audit_all_brands.ts create mode 100644 scripts/audit_four_brands.ts create mode 100644 scripts/complete_brandbooks.py create mode 100644 scripts/enrich_thin_dna.ts create mode 100644 scripts/ensure_ecp_tsp_drive.ts create mode 100644 scripts/fix_brand_gaps.ts create mode 100644 scripts/heal_agency_roles.ts create mode 100644 scripts/remediate_brand_truth.py create mode 100644 scripts/rename_brand_slugs.ts create mode 100644 scripts/split_tenants_and_fix.ts create mode 100644 src/app/api/calendar/linkedin-strategy/route.ts create mode 100644 src/app/api/change-history/undo/route.ts create mode 100644 src/lib/linkedin-strategy.ts diff --git a/scripts/audit_all_brands.ts b/scripts/audit_all_brands.ts new file mode 100644 index 0000000..54a811a --- /dev/null +++ b/scripts/audit_all_brands.ts @@ -0,0 +1,401 @@ +/** + * Full portfolio audit: Drive, brandbook, Meta/IG/FB/GAds, creatives folders. + * Optionally --fix to rematch Drive, enrich, ensure Ads/RRSS folders. + * + * npx tsx scripts/audit_all_brands.ts + * npx tsx scripts/audit_all_brands.ts --fix + */ +import { config } from 'dotenv'; +import { resolve } from 'path'; +config({ path: resolve(process.cwd(), '.env.local') }); + +import { enrichTenantBrandbook } from '../src/lib/brandbook-enrich'; +import { ensureCreativesDriveFolders } from '../src/lib/creatives-drive'; +import { + applyMatches, + listClientFoldersUnderClientes, + matchFoldersToTenants, + scoreFolderToTenant, +} from '../src/lib/google-drive'; +import { resolveBrandSlug } from '../src/lib/tenant-brand-aliases'; + +const FIX = process.argv.includes('--fix'); + +const url = process.env.NEXT_PUBLIC_SUPABASE_URL!; +const key = process.env.SUPABASE_SERVICE_ROLE_KEY!; +const h = { + apikey: key, + Authorization: `Bearer ${key}`, + 'Content-Type': 'application/json', +}; + +/** Meta IG user ids / page ids are numeric; reject profile URLs. */ +function isMetaNumericId(v: string | null | undefined): boolean { + return Boolean(v && /^\d{5,}$/.test(v.trim())); +} + +/** Ad account: act_123 or bare digits (normalize to act_ on fix). */ +function isMetaAdAccountId(v: string | null | undefined): boolean { + if (!v) return false; + const s = v.trim(); + return /^act_\d+$/i.test(s) || /^\d{5,}$/.test(s); +} + +function normalizeAdAccountId(v: string): string { + const s = v.trim(); + if (/^act_/i.test(s)) return s; + if (/^\d+$/.test(s)) return `act_${s}`; + return s; +} + +type Tenant = { + id: string; + slug: string; + name: string; + agency_followed?: boolean; + drive_folder_id?: string | null; + meta_ad_account_id?: string | null; + meta_page_id?: string | null; + meta_ig_user_id?: string | null; + default_landing_url?: string | null; + creatives_enabled?: boolean | null; +}; + +type Integ = { + id?: string; + tenant_id: string; + provider: string; + status: string; + external_account_id?: string | null; + label?: string | null; +}; + +export type Row = { + slug: string; + name: string; + followed: boolean; + brandbook: boolean; + driveTenant: boolean; + driveInteg: boolean; + driveLabel: string; + metaAds: boolean; + facebook: boolean; + instagram: boolean; + googleAds: boolean; + ga4: boolean; + issues: string[]; + scoreHint: number; +}; + +async function rest(path: string, init?: RequestInit): Promise { + const r = await fetch(`${url}/rest/v1/${path}`, { + ...init, + headers: { ...h, ...(init?.headers || {}) }, + }); + const text = await r.text(); + if (!r.ok) throw new Error(`${path} ${r.status}: ${text.slice(0, 300)}`); + return text ? (JSON.parse(text) as T) : ([] as T); +} + +async function main() { + const tenants = await rest( + 'tenants?select=id,slug,name,agency_followed,drive_folder_id,meta_ad_account_id,meta_page_id,meta_ig_user_id,default_landing_url,creatives_enabled&order=name.asc' + ); + const active = tenants.filter((t) => t.agency_followed !== false); + const archived = tenants.filter((t) => t.agency_followed === false); + + const integs = await rest( + 'integrations?select=id,tenant_id,provider,status,external_account_id,label' + ); + const byTenant = new Map(); + for (const i of integs) { + const arr = byTenant.get(i.tenant_id) || []; + arr.push(i); + byTenant.set(i.tenant_id, arr); + } + + const docs = await rest<{ tenant_id: string; type: string }[]>( + 'documents?type=eq.brandbook&select=tenant_id,type' + ); + const hasBook = new Set(docs.map((d) => d.tenant_id)); + + const { folders, clientesRoot } = await listClientFoldersUnderClientes(); + console.log( + `Clientes root=${clientesRoot?.name || '?'} folders=${folders.length} active=${active.length} archived=${archived.length} fix=${FIX}` + ); + + const rows: Row[] = []; + + for (const t of active) { + const list = byTenant.get(t.id) || []; + const connected = list.filter((i) => i.status === 'connected'); + const find = (p: string) => + connected.find((i) => i.provider === p) || + list.find((i) => i.provider === p); + + const drive = find('google_drive'); + const meta = find('meta_ads'); + const fb = find('facebook'); + const ig = find('instagram'); + const gads = find('google_ads'); + const ga4 = find('ga4'); + + const issues: string[] = []; + if (!hasBook.has(t.id)) issues.push('sin brandbook'); + if (!t.drive_folder_id && drive?.status !== 'connected') { + issues.push('sin Drive'); + } + if ( + drive?.status === 'connected' && + /navidad|black\s*friday|campa/i.test(drive.label || '') + ) { + issues.push(`Drive sospechoso: ${drive.label}`); + } + if (t.drive_folder_id && drive?.status === 'connected') { + if (drive.external_account_id !== t.drive_folder_id) { + issues.push('drive_folder_id ≠ integration'); + } + } + if (t.drive_folder_id && drive?.status !== 'connected') { + issues.push('drive_folder_id sin integration connected'); + } + + // Sync / validate tenant meta ids from integrations + if (!t.meta_ad_account_id && meta?.status === 'connected' && meta.external_account_id) { + issues.push('meta_ad_account_id vacío (hay integration)'); + } + if (t.meta_ad_account_id && !isMetaAdAccountId(t.meta_ad_account_id)) { + issues.push(`meta_ad_account_id inválido: ${t.meta_ad_account_id}`); + } else if ( + t.meta_ad_account_id && + /^\d+$/.test(t.meta_ad_account_id.trim()) && + t.meta_page_id === t.meta_ad_account_id + ) { + issues.push('meta_ad_account_id = meta_page_id (falta act_)'); + } + if (!t.meta_page_id && fb?.status === 'connected' && fb.external_account_id) { + issues.push('meta_page_id vacío'); + } + if (t.meta_page_id && !isMetaNumericId(t.meta_page_id)) { + issues.push(`meta_page_id inválido: ${t.meta_page_id}`); + } + if (!t.meta_ig_user_id && ig?.status === 'connected' && ig.external_account_id) { + issues.push('meta_ig_user_id vacío'); + } + if ( + t.meta_ig_user_id && + !isMetaNumericId(t.meta_ig_user_id) + ) { + issues.push(`meta_ig_user_id no numérico: ${t.meta_ig_user_id}`); + } + if ( + ig?.status === 'connected' && + ig.external_account_id && + !isMetaNumericId(ig.external_account_id) + ) { + issues.push(`IG integration no numérica: ${ig.external_account_id}`); + } + + // Best Drive folder candidate if missing + let scoreHint = 0; + if (!t.drive_folder_id || drive?.status !== 'connected') { + const scored = folders + .map((f) => ({ + f, + score: scoreFolderToTenant(f.name, t.name, t.slug), + })) + .filter((x) => x.score >= 70) + .sort((a, b) => b.score - a.score); + scoreHint = scored[0]?.score || 0; + if (scored[0]) { + issues.push(`Drive candidate: ${scored[0].f.name} (${scored[0].score})`); + } + } + + rows.push({ + slug: t.slug, + name: t.name, + followed: true, + brandbook: hasBook.has(t.id), + driveTenant: Boolean(t.drive_folder_id), + driveInteg: drive?.status === 'connected', + driveLabel: drive?.label || '', + metaAds: meta?.status === 'connected', + facebook: fb?.status === 'connected', + instagram: ig?.status === 'connected', + googleAds: gads?.status === 'connected', + ga4: ga4?.status === 'connected', + issues, + scoreHint, + }); + } + + // Print table + console.log('\n=== ACTIVE PORTFOLIO ==='); + for (const r of rows) { + const flags = [ + r.brandbook ? 'BB' : '--', + r.driveInteg ? 'DRV' : '--', + r.metaAds ? 'META' : '----', + r.instagram ? 'IG' : '--', + r.facebook ? 'FB' : '--', + r.googleAds ? 'GADS' : '----', + r.ga4 ? 'GA4' : '---', + ].join(' '); + const issueStr = r.issues.length ? ` ⚠ ${r.issues.join('; ')}` : ''; + console.log( + `${r.slug.padEnd(22)} ${flags} ${r.driveLabel.slice(0, 36).padEnd(36)}${issueStr}` + ); + } + + console.log('\n=== ARCHIVED ==='); + for (const t of archived) { + console.log(` ${t.slug} · ${t.name}`); + } + + if (FIX) { + console.log('\n=== FIXING ==='); + // Rematch Drive for those missing / suspicious + const needDrive = active.filter((t) => { + const list = byTenant.get(t.id) || []; + const drive = list.find((i) => i.provider === 'google_drive'); + if (drive?.status === 'connected' && /navidad/i.test(drive.label || '')) { + return true; + } + return !t.drive_folder_id || drive?.status !== 'connected'; + }); + + if (needDrive.length && folders.length) { + const matches = await matchFoldersToTenants( + needDrive.map((t) => ({ id: t.id, name: t.name, slug: t.slug })), + folders + ); + const good = matches.filter( + (m) => + m.status === 'matched' && + m.folderId && + !/navidad/i.test(m.folderName || '') + ); + if (good.length) { + await applyMatches(good); + for (const m of good) { + await rest(`tenants?id=eq.${m.tenantId}`, { + method: 'PATCH', + headers: { Prefer: 'return=minimal' }, + body: JSON.stringify({ drive_folder_id: m.folderId }), + }); + console.log(`[drive] ${m.tenantSlug} ← ${m.folderName}`); + } + } else { + console.log('[drive] no new high-confidence matches'); + } + } + + // Sync meta_* columns from connected integrations (numeric IDs only) + for (const t of active) { + const list = byTenant.get(t.id) || []; + const connected = list.filter((i) => i.status === 'connected'); + const patch: Record = {}; + const meta = connected.find((i) => i.provider === 'meta_ads'); + const fb = connected.find((i) => i.provider === 'facebook'); + const ig = connected.find((i) => i.provider === 'instagram'); + if (meta?.external_account_id && isMetaAdAccountId(meta.external_account_id)) { + const ad = normalizeAdAccountId(meta.external_account_id); + if (t.meta_ad_account_id !== ad) patch.meta_ad_account_id = ad; + if (meta.id && meta.external_account_id !== ad) { + await rest(`integrations?id=eq.${meta.id}`, { + method: 'PATCH', + headers: { Prefer: 'return=minimal' }, + body: JSON.stringify({ external_account_id: ad }), + }); + } + } else if (!t.meta_ad_account_id && meta?.external_account_id) { + console.warn(`[ids] skip bad meta_ads for ${t.slug}: ${meta.external_account_id}`); + } + if ( + (!t.meta_page_id || !isMetaNumericId(t.meta_page_id)) && + fb?.external_account_id && + isMetaNumericId(fb.external_account_id) + ) { + patch.meta_page_id = fb.external_account_id.trim(); + } + if ( + (!t.meta_ig_user_id || !isMetaNumericId(t.meta_ig_user_id)) && + ig?.external_account_id && + isMetaNumericId(ig.external_account_id) + ) { + patch.meta_ig_user_id = ig.external_account_id.trim(); + } else if ( + ig?.external_account_id && + !isMetaNumericId(ig.external_account_id) + ) { + console.warn(`[ids] skip bad IG for ${t.slug}: ${ig.external_account_id}`); + } + // Normalize bare digit ad account already on tenant + if ( + t.meta_ad_account_id && + /^\d+$/.test(t.meta_ad_account_id.trim()) && + !patch.meta_ad_account_id + ) { + patch.meta_ad_account_id = normalizeAdAccountId(t.meta_ad_account_id); + } + const drive = connected.find((i) => i.provider === 'google_drive'); + if ( + drive?.external_account_id && + !/navidad/i.test(drive.label || '') && + t.drive_folder_id !== drive.external_account_id + ) { + patch.drive_folder_id = drive.external_account_id; + } + if (Object.keys(patch).length) { + await rest(`tenants?id=eq.${t.id}`, { + method: 'PATCH', + headers: { Prefer: 'return=minimal' }, + body: JSON.stringify(patch), + }); + console.log(`[ids] ${t.slug}`, patch); + } + } + + // Enrich + creatives folders for all active + for (const t of active) { + const r = await enrichTenantBrandbook({ + tenantId: t.id, + forceSeed: !hasBook.has(t.id), + }); + const drive = await ensureCreativesDriveFolders( + t.id, + resolveBrandSlug(t.slug) || t.slug + ); + console.log( + `[enrich] ${t.slug} bb=${r.ok} ${r.completenessBefore}→${r.completenessAfter} drive=${drive.ok ? 'ok' : drive.warning || 'skip'}` + ); + } + } + + // Summary JSON for canvas + const summary = { + active: active.length, + archived: archived.length, + withBrandbook: rows.filter((r) => r.brandbook).length, + withDrive: rows.filter((r) => r.driveInteg).length, + withMeta: rows.filter((r) => r.metaAds).length, + withIg: rows.filter((r) => r.instagram).length, + withIssues: rows.filter((r) => r.issues.length).length, + rows, + archivedSlugs: archived.map((t) => t.slug), + }; + const outPath = resolve(process.cwd(), 'scripts/.audit_portfolio.json'); + await import('fs/promises').then((fs) => + fs.writeFile(outPath, JSON.stringify(summary, null, 2), 'utf8') + ); + console.log(`\nWrote ${outPath}`); + console.log( + `Summary: ${summary.withBrandbook}/${summary.active} BB · ${summary.withDrive}/${summary.active} Drive · ${summary.withMeta} Meta · ${summary.withIssues} with issues` + ); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/scripts/audit_four_brands.ts b/scripts/audit_four_brands.ts new file mode 100644 index 0000000..f34356d --- /dev/null +++ b/scripts/audit_four_brands.ts @@ -0,0 +1,82 @@ +/** + * Re-enrich TYD/TSP/ECP/DTATF + dump Drive folder scores. + * npx tsx scripts/audit_four_brands.ts + */ +import { config } from 'dotenv'; +import { resolve } from 'path'; +config({ path: resolve(process.cwd(), '.env.local') }); + +import { enrichTenantBrandbook } from '../src/lib/brandbook-enrich'; +import { ensureCreativesDriveFolders } from '../src/lib/creatives-drive'; +import { listClientFoldersUnderClientes } from '../src/lib/google-drive'; +import { scoreNameToTenant } from '../src/lib/match'; + +const FOCUS = ['tyd', 'tsp', 'ecp', 'dtatf'] as const; + +async function main() { + const url = process.env.NEXT_PUBLIC_SUPABASE_URL!; + const key = process.env.SUPABASE_SERVICE_ROLE_KEY!; + const h = { apikey: key, Authorization: `Bearer ${key}` }; + + const { folders, clientesRoot } = await listClientFoldersUnderClientes(); + console.log( + 'Drive root', + clientesRoot?.name || '(none)', + 'folders', + folders.length + ); + for (const f of [...folders].sort((a, b) => a.name.localeCompare(b.name))) { + console.log(' ', f.name); + } + + for (const slug of FOCUS) { + const tr = await fetch( + `${url}/rest/v1/tenants?slug=eq.${slug}&select=id,name,slug,drive_folder_id`, + { headers: h } + ); + const t = (await tr.json())[0] as + | { id: string; name: string; slug: string; drive_folder_id?: string } + | undefined; + if (!t) { + console.log(`[missing] ${slug}`); + continue; + } + + const scored = folders + .map((f) => ({ + name: f.name, + id: f.id, + score: scoreNameToTenant(f.name, t.name, t.slug), + })) + .filter((x) => x.score > 0) + .sort((a, b) => b.score - a.score) + .slice(0, 8); + console.log(`\n[${slug}] folder scores`, scored); + + const r = await enrichTenantBrandbook({ + tenantId: t.id, + forceSeed: true, + }); + console.log( + `[enrich] ${slug} ok=${r.ok} created=${r.created} score=${r.completenessAfter} warn=${r.warning || '-'} sources=${r.sources.join('+')}` + ); + + const drive = await ensureCreativesDriveFolders(t.id, slug); + console.log( + `[creatives-folders] ${slug}`, + drive.ok ? 'ok' : drive.warning + ); + + const bb = await fetch( + `${url}/rest/v1/documents?tenant_id=eq.${t.id}&type=eq.brandbook&select=id&limit=1`, + { headers: h } + ); + const rows = (await bb.json()) as { id: string }[]; + console.log(`[bb] ${slug}`, rows[0]?.id || 'MISSING'); + } +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/scripts/complete_brandbooks.py b/scripts/complete_brandbooks.py new file mode 100644 index 0000000..173729d --- /dev/null +++ b/scripts/complete_brandbooks.py @@ -0,0 +1,1711 @@ +#!/usr/bin/env python3 +""" +Completa brandbooks de tenants activos con datos reales de marca +(webs, IG, FB, LinkedIn, oferta, voz, ads, blog, compliance). +Corrige handles numéricos / webs erróneas. Preserva kit visual (colores/fuentes/assets). +""" +from __future__ import annotations + +import json +import re +import urllib.request +from pathlib import Path +from typing import Any + +ROOT = Path(__file__).resolve().parents[1] + + +def env(k: str) -> str: + for line in (ROOT / ".env.local").read_text().splitlines(): + if line.startswith(k + "="): + return line.split("=", 1)[1].strip().strip('"').strip("'") + return "" + + +URL = env("NEXT_PUBLIC_SUPABASE_URL") +KEY = env("SUPABASE_SERVICE_ROLE_KEY") +HR = {"apikey": KEY, "Authorization": f"Bearer {KEY}"} +HW = {**HR, "Content-Type": "application/json", "Prefer": "return=minimal"} + + +def get(path: str) -> Any: + req = urllib.request.Request(f"{URL}/rest/v1/{path}", headers=HR) + return json.loads(urllib.request.urlopen(req).read()) + + +def patch(path: str, body: dict) -> None: + req = urllib.request.Request( + f"{URL}/rest/v1/{path}", + data=json.dumps(body).encode(), + headers=HW, + method="PATCH", + ) + urllib.request.urlopen(req) + + +def handles( + ig: str = "", + fb: str = "", + li: str = "", + blog: str = "", + yt: str = "", + tiktok: str = "", +) -> dict: + return { + "instagram": ig, + "facebook": fb, + "linkedin": li, + "tiktok": tiktok, + "blog": blog, + "youtube": yt, + } + + +def channels( + ig: str = "", + fb: str = "", + li: str = "", + blog: str = "", + yt: str = "", + email: str = "", + tiktok: str = "", + other: str = "", +) -> dict: + return { + "instagram": ig, + "facebook": fb, + "linkedin": li, + "tiktok": tiktok, + "blog": blog, + "youtube": yt, + "email": email, + "other": other, + } + + +# --------------------------------------------------------------------------- +# PACKS — brand manager packs (datos verificados en webs / Meta / LinkedIn) +# --------------------------------------------------------------------------- + +PACKS: dict[str, dict[str, Any]] = { + "4ventures-hub": { + "brand_name": "4Ventures", + "website": "https://4ventures.es/", + "vertical": "Agencia boutique de marketing y ventas", + "tagline": "Aceleramos tu crecimiento con estrategia de marketing y ventas", + "elevator_pitch": "Agencia boutique que une marketing, ventas y operación para marcas que quieren crecer con sistema (España, EE.UU. y LatAm).", + "brand_promise": "Estrategia accionable, creatividades y performance bajo un mismo techo.", + "positioning": "Boutique de crecimiento: menos ruido, más sistema comercial y de marca.", + "archetype": "El Sabio + El Gobernante (claridad + ejecución)", + "mission": "Ayudar a empresas a vender más y mejor con marca, demanda y procesos medibles.", + "vision": "Ser el centro de mando de marketing y ventas de marcas ambiciosas.", + "values": ["Claridad", "Ejecución", "Transparencia", "Orientación a resultado"], + "tone": "Directo, experto, cercano a founders y directores de marketing; sin jerga vacía.", + "personality": ["estratégico", "operativo", "claro", "ambicioso"], + "voice_do": [ + "Hablar de métricas y sistemas", + "Mostrar casos y bastidores de ejecución", + "Proponer el siguiente paso concreto", + ], + "voice_dont": [ + "Promesas mágicas de viralidad", + "Jerga de agencia sin sustancia", + "Hablar como consultora genérica", + ], + "messaging_pillars": [ + "Marketing + ventas alineados", + "Creatividades con sistema", + "Datos y centro de mando", + "Equipo boutique", + ], + "example_good": [ + "No necesitás más anuncios: necesitás un sistema que convierta tráfico en pipeline.", + ], + "example_bad": ["Somos la mejor agencia del mercado 🚀🚀🚀"], + "icp": "PYMES y scale-ups B2B/B2C con ticket medio-alto que ya invierten en ads o RRSS y necesitan orden, marca y ROI.", + "geo": "España (HQ) · operaciones con clientes en EE.UU. y LatAm", + "languages": ["es", "en"], + "value_proposition": "Estrategia, creatividades, paid media y CRM/ventas integrados en un centro de mando.", + "products_services": [ + "Estrategia de marca y go-to-market", + "Creatividades Ads + RRSS", + "Meta Ads / Google Ads", + "RRSS y calendarios editoriales", + "SEO / web / inbound", + "CRM y procesos comerciales", + ], + "differentiators": [ + "Boutique con ownership real", + "Marketing y ventas en el mismo tablero", + "Producción creativa propia + performance", + ], + "proof_points": [ + "Portfolio multi-sector (formación, legal, beauty, B2B industrial)", + "Operación con Meta, Google y creatividades en Drive por cliente", + ], + "pillars_rrss": ["ops", "casos", "tips", "equipo", "thought-leadership"], + "series_recurrentes": ["Bastidores de campaña", "Errores que matan el ROAS", "Sistema comercial"], + "formats_priority": ["carrusel", "reel", "post", "stories"], + "channel_handles": handles( + ig="https://www.instagram.com/4lincesventures/", + fb="https://www.facebook.com/4ventures", + li="https://www.linkedin.com/company/4ventures-es/", + blog="https://4ventures.es/", + ), + "channels": channels( + ig="Marca, bastidores y prueba social", + fb="Comunidad y alcance local", + li="Autoridad B2B y captación de clientes agencia", + blog="SEO y artículos de método", + email="Nurturing y propuestas", + ), + "ads_usps": [ + "Marketing + ventas en un sistema", + "Creatividades con ADN de marca", + "Reporting accionable", + ], + "ads_ctas": ["Agendar diagnóstico", "Hablar con el equipo", "Ver método"], + "ads_objections": ["Ya tengo agencia", "Es caro", "No tengo tiempo de onboarding"], + "ads_angles": ["Orden vs caos", "ROAS con marca", "Equipo senior sin overhead"], + "ads_funnel": ["TOFU awareness", "MOFU método", "BOFU diagnóstico"], + "linkedin_themes": [ + "Go-to-market", + "Paid media con marca", + "Procesos comerciales", + "Leadership de growth", + ], + "linkedin_formats": ["carrusel método", "caso corto", "opinión founder"], + "linkedin_cta": ["Diagnóstico", "Mensaje directo", "Newsletter"], + "blog_themes": ["Performance", "Marca", "Ventas", "Stack de marketing"], + "blog_seo_keywords": [ + "agencia marketing españa", + "meta ads agencia", + "estrategia go to market", + ], + "blog_cta": ["Agendar llamada", "Descargar checklist"], + "claims_forbidden": ["Garantía de ROAS fijo", "Resultados en X días sin contexto"], + "required_disclaimers": ["Resultados dependen de sector, oferta y presupuesto"], + "emoji_policy": "Pocos, funcionales; evitar spam de cohetes.", + "b2b_offering": "Retainer de marketing integral + proyectos de creatividades y paid media.", + "b2c_offering": "", + "usp_differentiators": "Centro de mando: marca, creatividades, ads y ventas en un mismo hilo.", + "current_priorities": "Captación de clientes con ticket medio-alto y casos publicables.", + "jobs_to_be_done": [ + "Ordenar marketing disperso", + "Subir calidad creativa", + "Conectar ads con ventas", + ], + "buying_triggers": [ + "Cambio de agencia", + "Caída de ROAS", + "Lanzamiento de producto", + ], + "barriers": ["Miedo a cambiar de proveedor", "Presupuesto fragmentado"], + "customer_frustrations": "Agencias que entregan posts sin estrategia ni ownership de pipeline.", + "desired_transformation": "Pasar de ruido táctico a un sistema de crecimiento medible.", + "freeform_notes": "Operador por defecto marketing@4ventures.es. Hub interno + marca pública 4Ventures / @4lincesventures.", + }, + "agua-miia": { + "brand_name": "Agua Miia", + "website": "https://aguamiia.es/", + "vertical": "Agua filtrada por ósmosis inversa (hogar, empresa, hostelería)", + "tagline": "Agua filtrada por ósmosis inversa para hogar, empresa y hostelería", + "elevator_pitch": "Agua Miia lleva agua de alta pureza por ósmosis inversa a hogares, oficinas y hostelería, con instalación y servicio pensados para dejar de comprar garrafas.", + "brand_promise": "Agua más pura, más cómoda y con menos plástico de un solo uso.", + "positioning": "Ósmosis inversa por suscripción / servicio: pureza + comodidad vs garrafa y filtro básico.", + "archetype": "El Cuidador + El Explorador (salud + modernidad)", + "mission": "Facilitar acceso a agua de calidad en el punto de consumo.", + "vision": "Ser la referencia de agua filtrada inteligente en España.", + "values": ["Pureza", "Comodidad", "Sostenibilidad", "Cercanía"], + "tone": "Cercano, fresco, confiable; pedagogía suave sobre calidad del agua.", + "personality": ["limpio", "moderno", "cercano", "práctico"], + "voice_do": [ + "Hablar de beneficios cotidianos (sabor, cocina, café, piel)", + "Mostrar hogar/oficina reales", + "Explicar ósmosis sin tecnicismos extremos", + ], + "voice_dont": [ + "Miedo sanitario exagerado", + "Claims médicos no demostrados", + "Atacar marcas de agua embotellada con insultos", + ], + "messaging_pillars": [ + "Pureza 99%", + "Adiós a las garrafas", + "Hogar / empresa / hostelería", + "Sabor y hábitos saludables", + ], + "example_good": [ + "El café sabe distinto cuando el agua está bien filtrada. Ósmosis en casa, sin logistics de garrafas.", + ], + "example_bad": ["Cura enfermedades con nuestra agua"], + "icp": "Hogares urbanos conscientes + oficinas y HORECA que quieren agua de calidad sin logística de botellas.", + "geo": "España", + "languages": ["es"], + "value_proposition": "Ósmosis inversa con servicio: agua premium en el grifo, menos plástico, mejor sabor.", + "products_services": [ + "Equipos de ósmosis inversa", + "Instalación y mantenimiento", + "Soluciones hogar", + "Soluciones empresa", + "Soluciones hostelería", + ], + "differentiators": [ + "Enfoque suscripción/servicio", + "Cobertura hogar + B2B", + "Narrativa anti-garrafa", + ], + "proof_points": ["+99% purificación comunicada en RRSS", "Presencia IG activa @aguamiia"], + "pillars_rrss": ["producto", "hábitos", "antes-después", "B2B hostelería", "sostenibilidad"], + "series_recurrentes": ["El agua del café", "Adiós garrafas", "Miia en empresas"], + "formats_priority": ["reel", "stories", "carrusel", "post"], + "channel_handles": handles( + ig="https://www.instagram.com/aguamiia/", + fb="https://www.facebook.com/AguaMiia", + blog="https://aguamiia.es/", + ), + "channels": channels( + ig="Demanda y prueba de producto", + fb="Alcance local y remarketing", + blog="SEO y educación sobre ósmosis", + email="Leads y nurturing instalación", + ), + "ads_usps": [ + "Ósmosis inversa", + "Sin garrafas", + "Hogar y negocio", + ], + "ads_ctas": ["Pedir información", "Calcular ahorro", "WhatsApp"], + "ads_objections": ["Ya tengo filtro de jarra", "Es caro", "No quiero obras"], + "ads_angles": ["Sabor", "Ahorro vs embotellada", "Oficina sin plástico"], + "linkedin_themes": ["Bienestar en la oficina", "HORECA y calidad del agua", "ESG plástico"], + "linkedin_formats": ["caso B2B", "carrusel beneficio"], + "linkedin_cta": ["Solicitar demo empresa"], + "blog_themes": ["Calidad del agua", "Ósmosis vs otros filtros", "Hostelería"], + "blog_seo_keywords": [ + "ósmosis inversa hogar", + "agua filtrada suscripción", + "filtro agua oficina", + ], + "claims_forbidden": [ + "Cura o previene enfermedades", + "Afirmaciones médicas sin respaldo", + ], + "required_disclaimers": [ + "Beneficios referidos a calidad organoléptica y reducción de residuos, no a pretensiones terapéuticas", + ], + "b2b_offering": "Equipos y servicio para oficinas, clínicas y hostelería.", + "b2c_offering": "Ósmosis para el hogar con instalación.", + "usp_differentiators": "Pureza + servicio + menos plástico de un solo uso.", + "current_priorities": "Captación hogar y cuentas HORECA/empresa.", + "jobs_to_be_done": ["Mejorar sabor", "Eliminar cartera de garrafas", "Imagen sostenible"], + "buying_triggers": ["Mudanza", "Nacimiento/familia", "Apertura local"], + "customer_frustrations": "Garrafas pesadas, plástico y agua con mal sabor.", + "desired_transformation": "Agua buena siempre disponible en el grifo.", + "freeform_notes": "Web canónica https://aguamiia.es/ (también responde aguamiia.com). IG @aguamiia.", + }, + "bps": { + "brand_name": "Blue Parrot School", + "website": "https://blueparrotschool.com/", + "vertical": "Escuela de idiomas (inglés) · B2C + B2B FUNDAE", + "tagline": "Inglés real, cultura pop y certificaciones", + "elevator_pitch": "Escuela de inglés práctica (Madrid/Málaga + online) con metodología usable, series educativas y preparación oficial Cambridge/Aptis/IELTS; en B2B, Entidad Organizadora FUNDAE.", + "brand_promise": "Salir hablando con confianza, no solo aprobando teoría.", + "positioning": "Inglés útil + pop culture + certificación seria (vs apps de 15 min).", + "archetype": "El Sabio + El Bufón", + "mission": "Ayudar a alumnos a hablar inglés con confianza para trabajo, viajes y exámenes.", + "vision": "Referencia de inglés práctico y certificaciones en España.", + "values": ["Cercanía", "Rigor", "Diversión útil", "Progreso medible"], + "tone": "Cercano, divertido y riguroso; pop sin frivolidad.", + "personality": ["cercano", "divertido", "riguroso", "pop"], + "voice_do": [ + "Ejemplos reales y micro-lecciones", + "Series FALSE FRIEND / SLANG / MTW", + "Dato concreto > adjetivo vacío", + ], + "voice_dont": [ + "Black Friday genérico", + "Tono académico seco", + "Anglicismos de postureo (boost, unlock)", + ], + "messaging_pillars": [ + "Errores que delatan", + "Inglés de la calle / pop", + "Certificaciones con método", + "Profesor fijo + 60 min", + ], + "icp": "Adultos 25-45 que necesitan inglés real o certificado; empresas que quieren FUNDAE sin papeleo.", + "geo": "Madrid · Málaga · Online (España)", + "languages": ["es", "en"], + "value_proposition": "Inglés usable desde la primera semana, con camino claro a certificado y B2B FUNDAE llave en mano.", + "products_services": [ + "Cursos generales e intensivos", + "Trinity / IELTS / Cambridge / Aptis", + "Inglés oposiciones", + "1to1 y online", + "In-company + FUNDAE (Entidad Organizadora)", + ], + "channel_handles": handles( + ig="https://www.instagram.com/blueparrotschool/", + fb="https://www.facebook.com/blueparrotschool", + li="https://www.linkedin.com/company/blue-parrot-school/", + blog="https://blueparrotschool.com/", + ), + "channels": channels( + ig="Adquisición B2C y comunidad educativa", + fb="Alcance y remarketing cursos", + li="Captación B2B RRHH / FUNDAE", + blog="SEO exámenes y método", + email="Leads y matrículas", + ), + "ads_usps": [ + "Profesor fijo", + "Sesiones 60 min", + "FUNDAE Entidad Organizadora", + "Madrid / Málaga / Online", + ], + "ads_ctas": ["Test de nivel", "Reservar plaza", "Solicitar presupuesto B2B", "WhatsApp"], + "linkedin_themes": [ + "FUNDAE sin fricción", + "Inglés por vertical (Legal, IT, Financiero)", + "ROI formación idiomas", + ], + "blog_themes": ["False friends", "Preparación exámenes", "Inglés profesional"], + "blog_seo_keywords": [ + "academia inglés madrid", + "preparación cambridge", + "cursos inglés fundae", + ], + "claims_forbidden": ["Garantía de aprobado", "Nivel C1 en X semanas sin matices"], + "b2b_offering": "Programas in-company / online con gestión FUNDAE integral.", + "b2c_offering": "Cursos adultos, intensivos, 1to1 y preparación de exámenes oficiales.", + "usp_differentiators": "Profesor fijo + 60 min + especialización sectorial + FUNDAE propia.", + "current_priorities": "Matrículas B2C certificados + captación empresas FUNDAE.", + "pillars_rrss": [ + "false-friend", + "slang", + "certificaciones", + "método", + "comunidad", + ], + "freeform_notes": "Linktree: https://linktr.ee/blueparrotschool. Preservar DNA B2B FUNDAE ya en brandbook.", + }, + "brava-dermoestetica": { + "brand_name": "BRAVA Dermoestética", + "website": "https://www.bravadermoestetica.com/", + "vertical": "Clínica de dermoestética / enfermería estética (Madrid)", + "tagline": "Realza tu belleza natural", + "elevator_pitch": "Clínica de dermoestética avanzada en Barrio de Salamanca (Madrid) liderada por enfermeras especializadas: diagnóstico facial personalizado y tratamientos con resultado natural.", + "brand_promise": "Belleza que se nota en vitalidad, no en artificialidad.", + "positioning": "Enfermería dermoestética premium y cercana vs clínicas de volumen o estética agresiva.", + "archetype": "El Cuidador + El Amante (cuidado + belleza)", + "mission": "Devolver confianza con tratamientos seguros, personalizados y naturales.", + "vision": "Referencia de dermoestética natural en Madrid Salamanca.", + "values": ["Naturalidad", "Seguridad", "Personalización", "Cercanía clínica"], + "tone": "Cálido, experto, femenino-elegante; sin miedo ni promesas milagro.", + "personality": ["elegante", "cercana", "clínica", "natural"], + "voice_do": [ + "Educar sobre piel y tratamientos", + "Mostrar bastidores con profesionalidad", + "Insistir en valoración personalizada", + ], + "voice_dont": [ + "Antes/después engañosos", + "Garantizar resultados idénticos", + "Lenguaje quirúrgico agresivo", + ], + "messaging_pillars": [ + "Diagnóstico facial", + "Resultado natural", + "Enfermería especializada", + "Tratamientos avanzados", + ], + "icp": "Mujeres (y hombres) 25-55 en Madrid que buscan dermoestética segura y natural en Salamanca.", + "geo": "Calle de Castelló 41, Barrio de Salamanca, Madrid 28001", + "languages": ["es"], + "value_proposition": "Valoración personalizada + tratamientos de estética avanzada con enfoque enfermero y resultado natural.", + "products_services": [ + "Limpieza facial profunda", + "Peeling", + "Dermapen", + "Mesoterapia facial / capilar", + "PRP capilar", + "Polinucleótidos / biorevitalización", + "Bioestimuladores", + "Pack Piel Renovada", + "Valoración / test de piel", + ], + "differentiators": [ + "Equipo de enfermería dermoestética", + "Enfoque natural", + "Ubicación Salamanca", + ], + "proof_points": [ + "Clínica en Castelló 41", + "Comunidad IG @bravadermoestetica", + "Presencia Doctoralia", + ], + "pillars_rrss": [ + "educación-piel", + "tratamientos", + "bastidores-clínica", + "resultados-naturales", + "comunidad", + ], + "series_recurrentes": ["Qué necesita tu piel", "Mitos dermoestética", "Día en BRAVA"], + "formats_priority": ["reel", "stories", "carrusel", "post"], + "channel_handles": handles( + ig="https://www.instagram.com/bravadermoestetica/", + fb="https://www.facebook.com/BRAVADermoestetica", + blog="https://www.bravadermoestetica.com/", + ), + "channels": channels( + ig="Canal principal de captación y confianza", + fb="Remarketing local Madrid", + blog="Web + test de piel / captación leads", + other="Beacons/link-in-bio: https://beacons.ai/bravadermoestetica", + ), + "ads_usps": [ + "Resultado natural", + "Valoración personalizada", + "Salamanca, Madrid", + "Enfermería especializada", + ], + "ads_ctas": ["Pedir cita", "Test de piel gratis", "WhatsApp", "Reservar valoración"], + "ads_objections": ["Miedo a quedar artificial", "Dolor", "Precio", "Primera vez"], + "ads_angles": ["Naturalidad", "Primera valoración", "Pack piel renovada"], + "linkedin_themes": [], + "blog_themes": ["Cuidado facial", "Tratamientos", "Mitos estética"], + "blog_seo_keywords": [ + "dermoestética madrid", + "clínica estética salamanca madrid", + "mesoterapia facial madrid", + ], + "claims_forbidden": [ + "Resultados permanentes garantizados", + "Sin riesgos", + "Equivalencia a cirugía", + ], + "required_disclaimers": [ + "Resultados individuales variables", + "Información no sustituye valoración sanitaria", + ], + "sector_rules": "Publicidad sanitaria responsable; sin promesas milagro; consentimientos de imagen.", + "b2c_offering": "Tratamientos faciales y capilares de dermoestética avanzada.", + "usp_differentiators": "Belleza natural con criterio clínico de enfermería.", + "current_priorities": "Citas valoración + packs faciales en Madrid.", + "jobs_to_be_done": ["Mejorar textura/luminosidad", "Rejuvenecer sin artificialidad"], + "buying_triggers": ["Evento", "Cambio de estación", "Primera consulta gratis/test"], + "customer_frustrations": "Clínicas que empujan tratamientos sin escuchar la piel.", + "desired_transformation": "Piel cuidada y confianza sin verse 'hech@'.", + "freeform_notes": "Web oficial https://www.bravadermoestetica.com/ (NO usar solo beacons como web). Tel +34 628 625 958.", + }, +} + + +def pack_legal_delgado() -> dict: + return { + "brand_name": "Delgado Abogados", + "website": "https://abogadosdelgado.es/", + "vertical": "Despacho de abogados multidisciplinar · derecho bancario", + "tagline": "Despacho multidisciplinar con especial fortaleza en derecho bancario", + "elevator_pitch": "Delgado Abogados es un despacho multidisciplinar con fuerte expertise en derecho bancario y atención cercana a particulares y empresas.", + "brand_promise": "Asesoramiento legal claro, cercano y orientado a proteger tus derechos.", + "positioning": "Abogacía seria y cercana, con foco bancario, frente a despachos fríos o genéricos.", + "archetype": "El Cuidador + El Gobernante", + "mission": "Defender los intereses de clientes con rigor y cercanía.", + "vision": "Ser el despacho de referencia en banca y servicios legales integrales para sus clientes.", + "values": ["Rigor", "Cercanía", "Transparencia", "Compromiso"], + "tone": "Serio, cercano, autoridad legal sin frialdad ni fear-mongering.", + "personality": ["profesional", "cercano", "claro", "protector"], + "voice_do": [ + "Explicar en lenguaje entendible", + "Educar sobre derechos bancarios", + "Transmitir calma y control", + ], + "voice_dont": [ + "Garantizar resultados judiciales", + "Sensacionalismo", + "Atacar a bancos con insultos", + ], + "messaging_pillars": [ + "Derecho bancario", + "Casos y educación legal", + "Cercanía", + "Multidisciplinar", + ], + "icp": "Particulares y pymes afectados por productos bancarios o que necesitan asesoría legal integral.", + "geo": "España (despacho Delgado Abogados — web abogadosdelgado.es)", + "languages": ["es"], + "value_proposition": "Experiencia en derecho bancario + trato humano + capacidad multidisciplinar.", + "products_services": [ + "Derecho bancario", + "Asesoramiento a particulares", + "Asesoramiento a empresas", + "Litigación y gestiones asociadas", + ], + "channel_handles": handles( + ig="https://www.instagram.com/delgadoabogados/", + fb="https://www.facebook.com/delgadoabogados", + li="https://www.linkedin.com/company/abogados-delgado/", + blog="https://abogadosdelgado.es/", + ), + "channels": channels( + ig="Educación legal y confianza", + fb="Alcance y remarketing local", + li="Autoridad profesional y B2B suave", + blog="SEO jurídico / contenidos", + ), + "ads_usps": ["Expertos en derecho bancario", "Trato cercano", "Despacho multidisciplinar"], + "ads_ctas": ["Consulta", "Llamar", "WhatsApp", "Pedir información"], + "ads_objections": ["Es caro", "No sé si tengo caso", "Miedo a juzgados"], + "linkedin_themes": ["Derecho bancario", "Actualidad jurídica", "Consejos legales"], + "blog_themes": ["Cláusulas abusivas", "Reclamaciones bancarias", "Guías prácticas"], + "blog_seo_keywords": [ + "abogados derecho bancario", + "reclamación banco", + "despacho abogados", + ], + "claims_forbidden": [ + "Garantía de ganar el caso", + "Porcentajes de éxito inventados", + ], + "required_disclaimers": [ + "Cada caso requiere estudio individualizado", + "La información no constituye asesoramiento legal formal", + ], + "sector_rules": "Publicidad de servicios jurídicos: veracidad, no garantismo de resultado.", + "pillars_rrss": ["educativo-legal", "bancario", "casos", "confianza"], + "b2c_offering": "Defensa y asesoramiento a particulares (foco bancario).", + "b2b_offering": "Asesoramiento legal a empresas según áreas del despacho.", + "usp_differentiators": "Especialización bancaria + cercanía.", + "current_priorities": "Leads de consultas bancarias y branding de confianza.", + "jobs_to_be_done": ["Entender si tengo caso", "Reclamar al banco", "Sentirme acompañado"], + "buying_triggers": ["Carta del banco", "Hipoteca/cláusula", "Conflicto legal"], + "customer_frustrations": "Lenguaje jurídico opaco y despachos inaccesibles.", + "desired_transformation": "Claridad y defensa efectiva de mis derechos.", + "freeform_notes": "Web canónica https://abogadosdelgado.es/. LinkedIn company/abogados-delgado (no confundir con otros 'Delgado Abogados').", + } + + +PACKS["delgado-abogados"] = pack_legal_delgado() + +PACKS["dtatf"] = { + "brand_name": "De Tú a Tú Formación", + "website": "https://detuatuformacion.es/", + "vertical": "Formación profesional, soft skills, IA y empleabilidad (FUNDAE / abierta)", + "tagline": "Formación para la nueva era profesional", + "elevator_pitch": "detuatuformación diseña experiencias de aprendizaje innovadoras para profesionales y empresas: IA, liderazgo, inclusión, soft skills y empleabilidad, con acreditaciones FUNDAE y certificados de profesionalidad.", + "brand_promise": "Aprender de forma cercana, práctica y transformadora.", + "positioning": "Formación innovadora y humana (de tú a tú) frente a academias frías o genéricas.", + "archetype": "El Cuidador + El Sabio", + "mission": "Impulsar el crecimiento de personas y organizaciones con aprendizaje personalizado.", + "vision": "Ser el aliado de referencia en formación que transforma vidas y equipos.", + "values": ["Cercanía", "Innovación", "Impacto", "Inclusión"], + "origin_story": "Nace en 2015 de la visión de Meti Manuel y Marta Martín; crece con formación en abierto y proyectos in-company.", + "tone": "Cercano, práctico, motivador; transformación laboral sin postureo.", + "personality": ["cercano", "innovador", "humano", "profesional"], + "voice_do": [ + "Hablar de transformación real en el puesto de trabajo", + "Mostrar cursos y convocatorias con claridad", + "Tono de tú a tú", + ], + "voice_dont": [ + "Confundir marca con ECP / cuidadores", + "Promesas de empleo garantizado", + "Jerga vacía de 'revolucionar tu vida' sin oferta", + ], + "messaging_pillars": [ + "Cursos (IA, liderazgo, soft skills)", + "Empleabilidad", + "Testimonios", + "Convocatorias", + "Tips formación", + ], + "icp": "Profesionales que quieren upskilling y empresas que buscan formación in-company / FUNDAE.", + "geo": "Madrid (Calle Araucaria 22) · online · España", + "languages": ["es"], + "value_proposition": "Formación práctica e innovadora, cercana, con foco en IA, liderazgo e inclusión, para personas y empresas.", + "products_services": [ + "Cursos en abierto", + "Formación empresas / in-company", + "IA para profesionales y docentes", + "Liderazgo y soft skills", + "Inclusión LGTBIQ+ / protocolos RD", + "FUNDAE (entidad formadora/organizadora)", + "Certificados de profesionalidad (acreditación)", + ], + "differentiators": [ + "Metodología experiencial", + "De tú a tú", + "Catálogo IA + skills humanas", + "Marca hermana separada de ECP", + ], + "proof_points": [ + "Entidad FUNDAE", + "Centro acreditado certificados profesionalidad (CAM/SEPE según oferta)", + "Sede Madrid Tetuán", + ], + "pillars_rrss": ["cursos", "empleabilidad", "testimonios", "convocatorias", "tips-formacion"], + "series_recurrentes": ["Curso de la semana", "Tips empleabilidad", "IA en el trabajo"], + "channel_handles": handles( + ig="https://www.instagram.com/detuatuformacion/", + fb="https://www.facebook.com/detuatuformacion", + li="https://www.linkedin.com/company/detuatuformacion/", + blog="https://detuatuformacion.es/blog/", + ), + "channels": channels( + ig="Comunidad alumnos y lanzamientos", + fb="Convocatorias y familias/profesionales", + li="B2B empresas y thought leadership formación", + blog="SEO y contenidos de upskilling", + email="Leads y convocatorias", + ), + "ads_usps": [ + "Formación práctica", + "IA y skills del futuro", + "FUNDAE / bonificable (cuando aplique)", + "Cercanía de tú a tú", + ], + "ads_ctas": ["Más información", "Reservar plaza", "Hablar con un asesor", "Ver cursos"], + "ads_objections": ["No tengo tiempo", "Ya hice un curso similar", "Precio"], + "ads_angles": ["Docencia", "IA", "Liderazgo femenino", "Inclusión", "Empleabilidad"], + "linkedin_themes": [ + "Upskilling IA", + "Cultura y liderazgo", + "FUNDAE", + "Aprendizaje experiencial", + ], + "linkedin_formats": ["artículo corto", "carrusel curso", "caso empresa"], + "linkedin_cta": ["Solicitar info in-company", "Ver catálogo"], + "blog_themes": ["IA en la empresa", "Liderazgo", "Empleabilidad", "Inclusión"], + "blog_seo_keywords": [ + "formación empresas fundae", + "curso inteligencia artificial empresas", + "formación liderazgo", + ], + "claims_forbidden": ["Empleo garantizado", "Bonificación FUNDAE sin condiciones"], + "required_disclaimers": [ + "Condiciones FUNDAE según crédito y normativa vigente", + "Plazas y fechas según convocatoria", + ], + "b2b_offering": "Formación in-company y programas bonificables para equipos.", + "b2c_offering": "Cursos en abierto para profesionales.", + "usp_differentiators": "Innovación pedagógica + cercanía + catálogo IA/skills.", + "current_priorities": "Captación cursos IA/docencia y cuentas empresa.", + "jobs_to_be_done": ["Actualizar skills", "Bonificar formación", "Transformar equipos"], + "buying_triggers": ["Crédito FUNDAE a punto de caducar", "Nueva herramienta IA", "Convocatoria"], + "customer_frustrations": "Cursos teóricos que no se aplican al día a día.", + "desired_transformation": "Competencias aplicables y confianza profesional.", + "freeform_notes": "NO mezclar creatividades/Meta/Drive con ECP. LinkedIn company/detuatuformacion. Email info@detuatuformacion.es · +34 630 54 93 74.", +} + +PACKS["ecp"] = { + "brand_name": "ECP · Escuela de Cuidadores Profesionales", + "website": "https://escueladecuidadoresprofesionales.com/", + "vertical": "Formación sociosanitaria · certificados de profesionalidad (cuidados)", + "tagline": "Formación oficial para cuidadores profesionales", + "elevator_pitch": "Escuela especializada en formación de cuidadores profesionales con certificaciones de profesionalidad (SEPE/CAM) en atención sociosanitaria.", + "brand_promise": "Formarte con rigor para cuidar con profesionalidad.", + "positioning": "Escuela especializada en cuidadores vs academias generalistas o DTATF (marca hermana de soft skills/IA).", + "archetype": "El Cuidador + El Sabio", + "mission": "Profesionalizar el cuidado con formación oficial y cercana.", + "vision": "Referencia en formación sociosanitaria de cuidadores en España.", + "values": ["Cuidado", "Rigor", "Empleabilidad", "Cercanía"], + "tone": "Cercano, humano, claro; respeto por el sector sociosanitario.", + "personality": ["humano", "profesional", "claro", "motivador"], + "voice_do": [ + "Hablar de vocación y profesionalización", + "Explicar certificados y salidas", + "Testimonios de alumnos", + ], + "voice_dont": [ + "Mezclar con De Tú a Tú Formación", + "Prometer empleo fijo", + "Tonos infantiles o melodramáticos", + ], + "messaging_pillars": [ + "Certificados de profesionalidad", + "Atención sociosanitaria", + "Empleabilidad", + "Vocación de cuidado", + ], + "icp": "Personas que quieren trabajar o profesionalizarse como cuidadores; reconversión laboral sociosanitaria.", + "geo": "España (formación ECP)", + "languages": ["es"], + "value_proposition": "Formación especializada en cuidados con vía a certificación oficial.", + "products_services": [ + "Certificados de profesionalidad atención sociosanitaria", + "Formación para cuidadores profesionales", + "Itinerarios de empleabilidad sociosanitaria", + ], + "channel_handles": handles( + ig="https://www.instagram.com/escuelaecp/", + fb="https://www.facebook.com/Escueladecuidadoresprofesionales", + li="https://www.linkedin.com/company/ecp-escuela-de-cuidadores-profesionales/", + blog="https://escueladecuidadoresprofesionales.com/", + ), + "channels": channels( + ig="Captación y comunidad @escuelaecp", + fb="Convocatorias y alcance", + li="Autoridad sector sociosanitario", + blog="SEO certificados / cuidadores", + ), + "ads_usps": [ + "Especialistas en cuidadores", + "Certificación profesionalidad", + "Formación práctica", + ], + "ads_ctas": ["Más información", "Reservar plaza", "WhatsApp"], + "linkedin_themes": ["Profesionalización del cuidado", "Formación sociosanitaria"], + "blog_themes": ["Salidas profesionales", "Certificados", "Cuidados"], + "blog_seo_keywords": [ + "certificado profesionalidad atención sociosanitaria", + "curso cuidador profesional", + "escuela cuidadores", + ], + "claims_forbidden": ["Empleo garantizado", "Título universitario"], + "required_disclaimers": ["Requisitos y reconocimiento según convocatoria y normativa"], + "pillars_rrss": ["formacion", "empleabilidad", "testimonios", "convocatorias"], + "b2c_offering": "Cursos y certificados para futuros cuidadores profesionales.", + "usp_differentiators": "Especialización exclusiva en cuidadores (separada de DTATF).", + "current_priorities": "Convocatorias CP y captación Meta/IG.", + "jobs_to_be_done": ["Conseguir certificado", "Reorientar carrera al cuidado"], + "buying_triggers": ["Nueva convocatoria", "Desempleo / reconversión"], + "customer_frustrations": "Formación genérica que no habilita para el sector.", + "desired_transformation": "Cualificación reconocida para trabajar cuidando.", + "freeform_notes": "Marca hermana separada: DTATF. No mezclar Meta/Drive/creatividades.", +} + +PACKS["equinsa-parking"] = { + "brand_name": "Equinsa Parking", + "website": "https://equinsaparking.com/", + "vertical": "Fabricante de sistemas de gestión de aparcamientos", + "tagline": "Innovación y eficiencia en la gestión de aparcamiento", + "elevator_pitch": "Equinsa Parking fabrica e integra sistemas de gestión de aparcamientos (acceso, pago, guiado, LPR, ticketless) con +30 años de especialización y miles de instalaciones.", + "brand_promise": "Tecnología que humaniza el aparcamiento: eficiente, fiable y sin fricciones.", + "positioning": "Especialista industrial en parking systems (B2B) frente a integradores genéricos.", + "archetype": "El Creador + El Gobernante (tecnología + control)", + "mission": "Optimizar la operación de aparcamientos con tecnología propia y SAT nacional.", + "vision": "Referencia internacional en smart parking.", + "values": ["Fiabilidad", "Innovación", "Servicio", "Especialización"], + "tone": "Técnico-claro, B2B, confiable; sin hype startup vacío.", + "personality": ["industrial", "preciso", "innovador", "cercano-B2B"], + "voice_do": [ + "Hablar de instalaciones y gamas (Sense PRO, Essens, Espark)", + "Beneficios para operador y usuario", + "Datos de experiencia (+instalaciones, países)", + ], + "voice_dont": ["Promesas smart city vacías", "Confundir con Equinsa Networking"], + "messaging_pillars": [ + "Gamas de producto", + "Smart parking / ciudades", + "SAT y fiabilidad", + "Casos de instalación", + ], + "icp": "Operadores de parking, constructoras, ayuntamientos, hospitales, centros comerciales, parkings privados.", + "geo": "Torrejón de Ardoz (Madrid) · España · presencia internacional", + "languages": ["es", "en"], + "value_proposition": "Sistemas propios de parking + I+D + SAT nacional para maximizar operación y experiencia de usuario.", + "products_services": [ + "Gama Sense PRO", + "Gama Essens", + "Gama Espark (ticketless)", + "Control de acceso y pago", + "Guiado de vehículos", + "Reconocimiento de matrículas (LPR)", + "Centralización / on-line", + "App móvil", + "SAT postventa", + ], + "differentiators": [ + "+30 años especializados solo en parking", + "I+D propio", + "SAT propio líder", + "Miles de instalaciones", + ], + "proof_points": [ + "ISO 9001", + "+1700 / +3500 instalaciones (según materiales)", + "Sede C. Primavera 16, Torrejón de Ardoz", + ], + "pillars_rrss": ["producto", "instalaciones", "tips-parking", "b2b", "innovacion"], + "channel_handles": handles( + li="https://www.linkedin.com/company/equinsa-parking/", + yt="https://www.youtube.com/channel/UCWuygaSlmr2fpJKx8UIc3lA", + blog="https://equinsaparking.com/", + ), + "channels": channels( + li="Canal principal B2B y captación", + yt="Demo productos e instalaciones", + blog="SEO soluciones parking", + email="Presupuestos y proyectos", + other="Contacto sat@equinsa.es · +34 618 18 89 24", + ), + "ads_usps": [ + "Especialistas en parking", + "Tecnología propia", + "SAT nacional", + "Ticketless / LPR / guiado", + ], + "ads_ctas": ["Solicitar demo", "Hablar con comercial", "Descargar ficha", "Presupuesto"], + "ads_objections": ["Ya tengo proveedor", "Capex alto", "Integración compleja"], + "linkedin_themes": [ + "Smart parking", + "Ticketless", + "Experiencia de usuario", + "Casos de éxito", + "Mantenimiento SAT", + ], + "linkedin_formats": ["caso instalación", "ficha gama", "vídeo producto"], + "linkedin_cta": ["Pedir demo", "Contactar ventas"], + "blog_themes": ["Gestión de aparcamientos", "Movilidad", "Tecnología LPR"], + "blog_seo_keywords": [ + "sistemas gestión aparcamiento", + "parking ticketless", + "reconocimiento matrículas parking", + ], + "claims_forbidden": ["Garantía de ocupación 100%", "Compatibilidad universal sin estudio"], + "b2b_offering": "Diseño, suministro e integración de sistemas de parking + SAT.", + "usp_differentiators": "Especialización exclusiva + producto propio + SAT.", + "current_priorities": "Leads B2B LinkedIn/Google Ads y notoriedad de gamas.", + "jobs_to_be_done": ["Modernizar parking", "Reducir colas", "Centralizar operación"], + "buying_triggers": ["Obra nueva", "Renovación equipos", "Quejas de usuarios"], + "customer_frustrations": "Sistemas obsoletos y SAT lento.", + "desired_transformation": "Parking fluido, medible y fácil de operar.", + "freeform_notes": "Web https://equinsaparking.com/ (grupo también equinsa.es). LinkedIn company/equinsa-parking. IG no encontrado con handle propio fiable — dejar vacío.", +} + +PACKS["fbm-abogados"] = { + "brand_name": "FBM & Asociados", + "website": "https://fbmasociados.com/", + "vertical": "Despacho de abogados · herencias y sucesiones", + "tagline": "Abogados especialistas en herencias y sucesiones", + "elevator_pitch": "Franco Blanco Menchaca & Asociados es un despacho especializado en derecho sucesorio que ofrece servicio llave en mano tras un fallecimiento, con cobertura nacional.", + "brand_promise": "Acompañamiento empático y exhaustivo cuando más se necesita.", + "positioning": "Especialistas en herencias (llave en mano) vs generalistas.", + "archetype": "El Cuidador + El Sabio", + "mission": "Guiar a familias en procesos sucesorios con claridad, empatía y rigor.", + "vision": "Referencia nacional en derecho sucesorio cercano.", + "values": ["Empatía", "Rigor", "Cercanía", "Discreción"], + "tone": "Empático, claro, autoridad en sucesiones; humano sin sensacionalismo.", + "personality": ["empático", "claro", "profesional", "discreto"], + "voice_do": [ + "Acompañar sin alarmismo", + "Explicar pasos del proceso sucesorio", + "Ofrecer tranquilidad y siguiente paso", + ], + "voice_dont": [ + "Garantizar resultados", + "Usar dolor familiar como clickbait", + "Lenguaje frío de bufete elitista", + ], + "messaging_pillars": ["Herencias", "Educativo-legal", "Llave en mano", "Cobertura nacional"], + "icp": "Familias y herederos que necesitan gestionar una herencia o planificar sucesión; también gestión inmobiliaria asociada.", + "geo": "España (Madrid, Bilbao, Vitoria, red en crecimiento)", + "languages": ["es"], + "value_proposition": "Asesoramiento sucesorio personal y asistencial, llave en mano, a nivel nacional.", + "products_services": [ + "Herencias y sucesiones", + "Instancias / tramitación hereditaria", + "Asesoramiento post-fallecimiento", + "Gestión inmobiliaria asociada", + "Otras ramas del derecho (despacho multidisciplinar con foco sucesorio)", + ], + "channel_handles": handles( + ig="https://www.instagram.com/fbmasociados/", + blog="https://fbmasociados.com/", + ), + "channels": channels( + ig="Educación y cercanía @fbmasociados", + blog="SEO herencias / guías", + email="Captación consultas", + other="madrid@fbmasociados.com · +34 911 97 18 64", + ), + "ads_usps": [ + "Especialistas en herencias", + "Servicio llave en mano", + "Cobertura nacional", + "Trato humano", + ], + "ads_ctas": ["Consulta", "Pedir información", "Llamar", "WhatsApp"], + "ads_objections": ["Es caro", "No sé por dónde empezar", "Ya tengo notario"], + "linkedin_themes": ["Derecho sucesorio", "Planificación hereditaria", "Guías prácticas"], + "blog_themes": ["Pasos de una herencia", "Impuestos sucesiones", "Preguntas frecuentes"], + "blog_seo_keywords": [ + "abogados herencias", + "gestión sucesiones", + "tramitar herencia españa", + ], + "claims_forbidden": ["Garantía de resultado", "Plazos fijos universales"], + "required_disclaimers": [ + "Cada herencia es distinta", + "La información no sustituye consulta personalizada", + ], + "sector_rules": "Publicidad jurídica prudente; sensibilidad con duelo.", + "pillars_rrss": ["herencias", "educativo-legal", "casos", "confianza"], + "b2c_offering": "Tramitación y asesoramiento sucesorio a familias.", + "usp_differentiators": "Especialización sucesoria + llave en mano + red nacional.", + "current_priorities": "Leads de herencias y branding de confianza.", + "jobs_to_be_done": ["Tramitar herencia sin caos", "Entender impuestos y plazos"], + "buying_triggers": ["Fallecimiento reciente", "Conflicto entre herederos"], + "customer_frustrations": "Procesos opacos y falta de acompañamiento emocional.", + "desired_transformation": "Herencia resuelta con claridad y menos carga mental.", + "freeform_notes": "Razón social Franco Blanco Menchaca & Asociados. Web https://fbmasociados.com/. IG @fbmasociados.", +} + +PACKS["halal-safety"] = { + "brand_name": "Halal Safety", + "website": "https://www.halalsafety.com/", + "vertical": "Certificación Halal (España / Europa / internacional)", + "tagline": "Certificación Halal reconocida a nivel internacional", + "elevator_pitch": "Halal Safety es una entidad de certificación Halal con +15 años de trayectoria que ayuda a empresas alimentarias y afines a obtener y mantener certificación reconocida internacionalmente.", + "brand_promise": "Certificación Halal rigurosa, clara y con reconocimiento internacional.", + "positioning": "Entidad de certificación seria y global vs sellos locales poco reconocidos.", + "archetype": "El Gobernante + El Sabio (estándar + pedagogía)", + "mission": "Facilitar el acceso de empresas a mercados Halal con certificación confiable.", + "vision": "Referencia europea de certificación Halal de confianza.", + "values": ["Integridad", "Rigor", "Internacionalización", "Claridad"], + "tone": "Profesional, claro, intercultural; autoridad técnica sin arrogancia.", + "personality": ["riguroso", "internacional", "claro", "cercano-B2B"], + "voice_do": [ + "Explicar beneficios de certificación para exportar", + "Mostrar proceso y confianza", + "Hablar a calidad/export/dirección", + ], + "voice_dont": ["Estereotipos religiosos", "Prometer mercados sin matices"], + "messaging_pillars": [ + "Certificación internacional", + "Proceso claro", + "Mercados Halal", + "Confianza y compliance", + ], + "icp": "Empresas agroalimentarias, cosmética, logística y exportadoras que necesitan sello Halal.", + "geo": "España · Europa · proyección internacional", + "languages": ["es", "en"], + "value_proposition": "Certificación Halal con reconocimiento internacional y acompañamiento experto.", + "products_services": [ + "Certificación Halal", + "Auditorías y acompañamiento", + "Renovaciones / mantenimiento", + "Asesoría para acceso a mercados Halal", + ], + "channel_handles": handles( + ig="https://www.instagram.com/halalsafety/", + fb="https://www.facebook.com/SAFETYHORIZON", + li="https://www.linkedin.com/company/halal-safety-certification/", + blog="https://www.halalsafety.com/", + ), + "channels": channels( + ig="Notoriedad y educación @halalsafety", + fb="Comunidad / Safety Horizon", + li="Captación B2B principal", + blog="SEO certificación Halal", + ), + "ads_usps": [ + "+15 años", + "Reconocimiento internacional", + "Proceso experto", + "España y Europa", + ], + "ads_ctas": ["Solicitar información", "Hablar con un auditor", "Empezar certificación"], + "linkedin_themes": [ + "Exportación mercados Halal", + "Compliance alimentario", + "Casos de certificación", + ], + "blog_themes": ["Qué es Halal", "Proceso de certificación", "Mercados"], + "blog_seo_keywords": [ + "certificación halal españa", + "certificado halal empresas", + "halal certification europe", + ], + "claims_forbidden": ["Acceso automático a todos los mercados", "Certificación en 24h sin auditoría"], + "b2b_offering": "Certificación y acompañamiento Halal para empresas.", + "usp_differentiators": "Trayectoria + reconocimiento internacional + foco Europa.", + "current_priorities": "Leads LinkedIn/Google y educación de marca.", + "pillars_rrss": ["certificacion", "educativo", "mercados", "confianza"], + "jobs_to_be_done": ["Obtener sello Halal", "Exportar con confianza"], + "buying_triggers": ["Oportunidad de export", "Cliente exige sello", "Auditoría próxima"], + "customer_frustrations": "Procesos opacos y sellos sin reconocimiento.", + "desired_transformation": "Certificación clara que abre mercados.", + "freeform_notes": "LinkedIn principal company/halal-safety-certification. También aparece eci-safety-horizon en web.", +} + +PACKS["la-freseria"] = { + "brand_name": "La Fresería", + "website": "https://lafreseria.es/", + "vertical": "Retail food · fresas con toppings / foodie viral (franquicia)", + "tagline": "Las fresas más famosas del mundo", + "elevator_pitch": "La Fresería es la marca de fresas con chocolate y creaciones foodie virales con decenas de locales en España y Europa, y modelo de franquicia.", + "brand_promise": "Una experiencia dulce, visual y adictiva alrededor de la fresa.", + "positioning": "Foodie viral de fresas premium vs neverías genéricas.", + "archetype": "El Bufón + El Amante (diversión + indulgencia)", + "mission": "Hacer de la fresa un momento memorable y compartible.", + "vision": "Llevar las fresas más famosas del mundo a más ciudades (franquicia).", + "values": ["Frescura", "Diversión", "Calidad", "Comunidad"], + "tone": "Fresco, apetitoso, cercano, estacional y con energía social.", + "personality": ["divertido", "visual", "cercano", "goloso"], + "voice_do": [ + "Mostrar producto hero (ASMR/close-up)", + "Estacionalidad y lanzamientos", + "UGC y comunidad", + ], + "voice_dont": ["Claims nutricionales engañosos", "Tono corporativo frío"], + "messaging_pillars": ["Producto", "Novedades", "Tiendas", "Franquicia", "Comunidad"], + "icp": "Gen Z / millennials foodies + familias; inversores/franquiciados para expansión.", + "geo": "España (+ Portugal/Francia en red) · HQ Madrid", + "languages": ["es"], + "value_proposition": "Fresas 100% nacionales y creaciones virales en locales pequeños de alta rotación.", + "products_services": [ + "Fresas con chocolate y toppings", + "Creaciones de temporada (Fresalado, matcha, ube, etc.)", + "Cajitas / regalos", + "Franquicia La Fresería", + ], + "differentiators": [ + "Concepto viral de fresa", + "Modelo de local pequeño sin salida de humos", + "Red >30 locales España", + ], + "proof_points": [ + "+33 locales en España / +35 en Europa (según web franquicia)", + "Inversión franquicia desde ~65.000 € (material web)", + ], + "pillars_rrss": ["producto", "lanzamientos", "tiendas", "ugc", "franquicia"], + "series_recurrentes": ["Novedad de temporada", "Detrás del mostrador", "Spotting tiendas"], + "formats_priority": ["reel", "stories", "carrusel", "post"], + "channel_handles": handles( + ig="https://www.instagram.com/lafreseriaoficial/", + blog="https://lafreseria.es/", + ), + "channels": channels( + ig="Canal héroe @lafreseriaoficial", + blog="Web tiendas + franquicia", + email="Franquicia expansion@lafreseria.com / hola@lafreseria.com", + other="TikTok/other UGC según campaña", + ), + "ads_usps": [ + "Fresas más famosas del mundo", + "Novedades virales", + "Regalo perfecto", + "Franquicia rentable (B2B)", + ], + "ads_ctas": ["Encuentra tu tienda", "Pedir", "Info franquicia", "Síguenos"], + "ads_angles": ["Producto hero", "Temporada", "Franquicia EBITDA"], + "linkedin_themes": ["Expansión franquicia", "Food retail", "Emprendimiento"], + "blog_themes": ["Novedades", "Franquicia", "Tiendas"], + "blog_seo_keywords": ["fresas con chocolate", "franquicia food", "la fresería tiendas"], + "claims_forbidden": ["Claims de salud / dietéticos engañosos", "EBITDA garantizado"], + "required_disclaimers": [ + "Cifras de franquicia orientativas según material comercial", + "Disponibilidad de producto según tienda/temporada", + ], + "b2b_offering": "Modelo de franquicia (expansión).", + "b2c_offering": "Producto foodie en tienda.", + "usp_differentiators": "Concepto fresa viral + red europea + formato local ágil.", + "current_priorities": "Tráfico a tiendas + leads franquicia.", + "jobs_to_be_done": ["Darme un capricho visual", "Regalar", "Abrir franquicia"], + "buying_triggers": ["Novedad", "Cumpleaños", "Apertura ciudad"], + "customer_frustrations": "Postres feos o poco instagrameables.", + "desired_transformation": "Momento wow para compartir.", + "freeform_notes": "Razón social BRUTAL VIRAL FOODIES, S.L. Web https://lafreseria.es/. IG @lafreseriaoficial.", +} + +PACKS["noberplast"] = { + "brand_name": "Noberplast", + "website": "https://noberplast.com/", + "vertical": "Fabricación y distribución de envases, bolsas y productos para hogar/comercio/HORECA", + "tagline": "Productos para el cuidado del hogar, comercio y profesionales", + "elevator_pitch": "Noberplast fabrica y distribuye bolsas de papel y plástico, celulosa, aluminio y envases para hogar, comercios y profesionales, con +30–40 años de experiencia y logística propia.", + "brand_promise": "Suministro fiable, amplio catálogo y entregas ágiles para que tu negocio no pare.", + "positioning": "Partner de suministros HORECA/comercio con fabricación/distribución propia vs puro marketplace.", + "archetype": "El Cuidador + El Gobernante (servicio + fiabilidad)", + "mission": "Simplificar y rentabilizar las compras de consumibles para negocios y hogar.", + "vision": "Ser el partner de referencia de suministros desechables y celulosa en la península.", + "values": ["Fiabilidad", "Servicio", "Calidad", "Cercanía comercial"], + "tone": "Práctico, claro, B2B cercano; orientado a catálogo y beneficio operativo.", + "personality": ["fiable", "práctico", "industrial-cercano", "resolutivo"], + "voice_do": [ + "Hablar de catálogo y ahorro por volumen", + "Destacar plazos de entrega y stock", + "Casos HORECA / comercio", + ], + "voice_dont": ["Greenwashing sin matiz", "Prometer plazos imposibles"], + "messaging_pillars": ["Catálogo", "Logística 48h", "HORECA", "Personalización bolsas", "Sostenibilidad responsable"], + "icp": "Comercios, HORECA, profesionales e industria que compran consumibles de forma recurrente.", + "geo": "Ajalvir (Madrid) · Península Ibérica", + "languages": ["es"], + "value_proposition": "Catálogo amplio + experiencia + vehículos propios y entregas rápidas en península.", + "products_services": [ + "Bolsas papel y plástico (también personalizadas)", + "Celulosa / papel multiusos", + "Aluminio y envases", + "Productos limpieza / hogar", + "Suministros HORECA", + "Venta online B2B/B2C", + ], + "differentiators": [ + "+30/40 años", + "Logística propia", + "ISO 9001 / 14001", + "Personalización", + ], + "proof_points": [ + "ISO 9001, ISO 14001", + "Entregas ~48h península", + "Tel. 800 76 00 53 / 91 658 42 42", + ], + "pillars_rrss": ["producto", "catalogo", "tips-negocio", "sostenibilidad", "bastidores"], + "channel_handles": handles( + ig="https://www.instagram.com/noberplastsl/", + li="https://www.linkedin.com/company/noberplast/", + blog="https://noberplast.com/", + ), + "channels": channels( + ig="@noberplastsl producto y marca", + li="B2B y captación mayorista", + blog="SEO catálogo / consejos", + email="Pedidos y cuentas clave", + ), + "ads_usps": [ + "Amplio catálogo", + "Entrega rápida", + "Precios por volumen", + "40 años de experiencia", + ], + "ads_ctas": ["Ver catálogo", "Pedir presupuesto", "Comprar online", "Llamar"], + "linkedin_themes": ["Suministros HORECA", "Envases", "Logística", "Sostenibilidad materiales"], + "blog_themes": ["Consejos hostelería", "Productos destacados", "Navidad/temporada"], + "blog_seo_keywords": [ + "bolsas papel personalizadas", + "suministros horeca", + "distribuidor celulosa madrid", + ], + "b2b_offering": "Suministro recurrente y personalización para comercios y HORECA.", + "b2c_offering": "Tienda online de productos hogar/profesionales.", + "usp_differentiators": "Fabricación/distribución + logística propia + certificaciones.", + "current_priorities": "Pedidos online y cuentas B2B.", + "jobs_to_be_done": ["Reponer stock sin roturas", "Bajar coste por unidad", "Personalizar packaging"], + "buying_triggers": ["Rotura de stock", "Nueva apertura", "Cambio de proveedor"], + "customer_frustrations": "Proveedores lentos o catálogo incompleto.", + "desired_transformation": "Suministro estable y simple.", + "freeform_notes": "Web https://noberplast.com/. IG @noberplastsl. LinkedIn company/noberplast. Crta. Torrejón-Ajalvir Km 0,6 nº16, 28864 Ajalvir.", +} + +PACKS["tsp"] = { + "brand_name": "Talento Scholar Program", + "website": "https://talentoscholarprogram.com/", + "vertical": "Certificaciones universitarias USA para estudiantes preuniversitarios", + "tagline": "Certificaciones universitarias americanas antes de la universidad", + "elevator_pitch": "Talento Scholar Program permite a estudiantes de 4º ESO, Bachillerato y FP obtener certificaciones oficiales de universidad americana (p. ej. Utah Tech) 100% online, con acompañamiento.", + "brand_promise": "Un perfil académico diferencial e internacional antes de entrar en la universidad.", + "positioning": "Línea académica/scholar de Talento (separada de becas deportivas TYD).", + "archetype": "El Sabio + El Explorador", + "mission": "Dar a estudiantes españoles acceso real a credencial universitaria USA mientras cursan Bach/FP.", + "vision": "Ser el puente de referencia hacia certificaciones universitarias americanas desde España.", + "values": ["Excelencia", "Accesibilidad", "Acompañamiento", "Internacionalización"], + "tone": "Orientador, serio-cercano, aspiracional académico (padres + alumnos).", + "personality": ["orientador", "aspiracional", "claro", "cercano"], + "voice_do": [ + "Hablar a padres y alumnos", + "Explicar certificaciones y compatibilidad con el cole/FP", + "Destacar Utah Tech / oficialidad", + ], + "voice_dont": [ + "Mezclar con becas deportivas TYD", + "Prometer admisión universitaria garantizada", + "Vender como 'título completo USA' confuso", + ], + "messaging_pillars": [ + "Certificación universitaria USA", + "4º ESO / Bach / FP", + "100% online", + "Perfil diferencial", + "Acompañamiento", + ], + "icp": "Familias con hijos en 4º ESO–Bach–FP que buscan diferenciación académica internacional.", + "geo": "España (online) · partner universidad USA", + "languages": ["es", "en"], + "value_proposition": "Certificaciones oficiales de universidad americana compatibles con estudios actuales, con orientación del equipo.", + "products_services": [ + "Technology Innovation & Entrepreneurship", + "Cybersecurity", + "Photography & Digital Imaging", + "Orientación y acompañamiento al estudiante", + ], + "channel_handles": handles( + ig="https://www.instagram.com/talentoscholarprogram/", + blog="https://talentoscholarprogram.com/", + ), + "channels": channels( + ig="Marca y prueba social @talentoscholarprogram", + blog="Landing + captación leads", + email="Nurturing familias", + other="Powered by Talento y Deporte (marca hermana TYD — no mezclar Meta/creatividades)", + ), + "ads_usps": [ + "Universidad americana", + "Antes de la universidad", + "100% online", + "Compatible con cole/FP", + ], + "ads_ctas": ["Solicitar información", "Quiero información", "Hablar con el equipo"], + "ads_objections": [ + "Es caro", + "Nivel de inglés", + "No sirve en España", + "Es solo un curso más", + ], + "linkedin_themes": [ + "Educación internacional", + "Credentialing preuniversitario", + "EdTech", + ], + "blog_themes": ["Por qué certificar antes", "Áreas del programa", "FAQ padres"], + "blog_seo_keywords": [ + "certificación universitaria americana", + "cursos universidad eeuu bachillerato", + "talento scholar program", + ], + "claims_forbidden": [ + "Admisión garantizada a universidades", + "Equivalencia automática a grado", + ], + "required_disclaimers": [ + "Las certificaciones dependen del programa y requisitos de la universidad partner", + "No garantiza admisión universitaria", + ], + "pillars_rrss": [ + "beca-academica", + "admisiones", + "campus", + "financiacion", + "objeciones", + "casos-exito", + ], + "b2c_offering": "Programa de certificaciones para estudiantes y familias.", + "usp_differentiators": "Credencial USA oficial + online + acompañamiento TYD ecosystem.", + "current_priorities": "Leads de información y branding Scholar separado de deporte.", + "jobs_to_be_done": ["Diferenciar el CV del hijo", "Explorar camino internacional"], + "buying_triggers": ["Orientación bachillerato", "Interés STEM/USA", "Comparar con extracurriculares"], + "customer_frustrations": "Extracurriculares que no aportan credencial real.", + "desired_transformation": "Perfil académico internacional verificable.", + "freeform_notes": "Web https://talentoscholarprogram.com/. IG @talentoscholarprogram. GA4 G-3ERLFE3PVL. Hermana TYD: no mezclar.", +} + +PACKS["tyd"] = { + "brand_name": "Talento y Deporte", + "website": "https://talentoydeporte.com/", + "vertical": "Becas deportivas y académicas para estudiar en EE.UU.", + "tagline": "Te llevamos a estudiar a Estados Unidos con beca deportiva o académica", + "elevator_pitch": "Talento y Deporte ayuda a jóvenes deportistas y estudiantes a conseguir becas en universidades de EE.UU., con acompañamiento en el proceso de admisión y colocación.", + "brand_promise": "Un camino real y acompañado hacia estudiar y competir en Estados Unidos.", + "positioning": "Especialistas en becas USA (deporte + academic pathways) vs agencias genéricas de 'estudia fuera'.", + "archetype": "El Héroe + El Cuidador (ambición + acompañamiento familiar)", + "mission": "Abrir puertas universitarias en EE.UU. a talento español e internacional.", + "vision": "Ser la referencia ibérica en placement universitario USA.", + "values": ["Ambición", "Transparencia", "Acompañamiento", "Excelencia deportiva/académica"], + "tone": "Motivador, aspiracional, claro para familias; serio en el proceso.", + "personality": ["aspiracional", "cercano", "exigente", "orientador"], + "voice_do": [ + "Hablar de becas, universidades y proceso", + "Mostrar casos de alumnos", + "Dirigirse a padres y atletas", + ], + "voice_dont": [ + "Mezclar con Talento Scholar Program", + "Garantizar beca del 100%", + "Vender turismo académico", + ], + "messaging_pillars": [ + "Becas deportivas USA", + "Proceso paso a paso", + "Casos de éxito", + "Vida universitaria", + "Familias", + ], + "icp": "Familias con hijos deportistas / estudiantes con proyección que buscan universidad en EE.UU.", + "geo": "España · LatAm (operaciones TYD) · destino EE.UU.", + "languages": ["es", "en"], + "value_proposition": "Proceso guiado para becas deportivas/académicas en universidades USA con equipo experto.", + "products_services": [ + "Asesoramiento becas deportivas", + "Placement universitario USA", + "Preparación de perfil / video / academic", + "Acompañamiento a familias", + ], + "channel_handles": handles( + ig="https://www.instagram.com/talentoydeporte/", + fb="https://www.facebook.com/talentoydeporte", + yt="https://www.youtube.com/@talentoydeporte", + blog="https://talentoydeporte.com/", + ), + "channels": channels( + ig="Canal héroe @talentoydeporte", + fb="Alcance familias", + yt="Historias y proceso", + blog="SEO becas USA", + email="Nurturing familias", + other="Linktree https://linktr.ee/talentoydeporte", + ), + "ads_usps": [ + "Becas en EE.UU.", + "Acompañamiento real", + "Casos de éxito", + "Deporte + estudios", + ], + "ads_ctas": ["Pedir información", "Evalúa tu perfil", "Hablar con un asesor"], + "linkedin_themes": ["Educación internacional", "Deporte universitario", "Employability"], + "blog_themes": ["Cómo funciona la beca", "Vida en USA", "Requisitos"], + "blog_seo_keywords": [ + "becas deportivas estados unidos", + "estudiar en usa con beca", + "universidad americana deporte", + ], + "claims_forbidden": ["Beca 100% garantizada", "Visa/admision asegurada"], + "required_disclaimers": [ + "La beca depende del perfil deportivo/académico y de la universidad", + ], + "pillars_rrss": ["becas", "casos", "proceso", "vida-usa", "familias"], + "b2c_offering": "Programa de asesoramiento y placement para familias/atletas.", + "usp_differentiators": "Especialización becas USA + track record + marca TYD.", + "current_priorities": "Leads España (y mercados TYD) sin canibalizar Scholar.", + "jobs_to_be_done": ["Conseguir beca", "Entender el proceso", "Elegir universidad"], + "buying_triggers": ["Temporada de scouting", "Fin de etapa escolar", "Torneo destacado"], + "customer_frustrations": "Agencias opacas y falsas promesas de beca total.", + "desired_transformation": "Plaza universitaria USA con plan claro.", + "freeform_notes": "Hermana TSP (Scholar) separada. GSC https://talentoydeporte.com/.", +} + +PACKS["virginia-molet"] = { + "brand_name": "Gravitae Academy (Virginia Molet)", + "website": "https://gravitaeacademy.com/", + "vertical": "Executive coaching / leadership academy", + "tagline": "Transformación del liderazgo a través de un coaching de primer nivel", + "elevator_pitch": "Gravitae Academy, fundada por Virginia Molet (ICF PCC), ayuda a ejecutivos de alto rendimiento a convertirse en líderes reconocidos mediante coaching ejecutivo y de equipos.", + "brand_promise": "Liderazgo con gravitas: influencia, claridad y crecimiento de carrera.", + "positioning": "Coaching ejecutivo premium internacional (ICF) vs coaching genérico o motivational speaking.", + "archetype": "El Sabio + El Magician (transformación)", + "mission": "Elevar el liderazgo de ejecutivos y equipos en contextos complejos e internacionales.", + "vision": "Red de coaching de primer nivel para líderes globales.", + "values": ["Excelencia", "Confidencialidad", "Profundidad", "Impacto"], + "tone": "Cercano-premium, internacional (ES/EN), aspiracional sin postureo vacío.", + "personality": ["premium", "internacional", "cercana", "exigente"], + "voice_do": [ + "Hablar de liderazgo, carrera e influencia", + "Credenciales ICF / experiencia +20 años / +50 nacionalidades", + "Casos y frameworks prácticos", + ], + "voice_dont": [ + "Frases motivacionales vacías", + "Prometer ascenso/salario garantizado", + "Confundir marca personal con clínica o producto físico", + ], + "messaging_pillars": [ + "Executive coaching", + "Liderazgo", + "Carrera", + "Equipos / systemic coaching", + "Comunidad Gravitae", + ], + "icp": "Directores, heads y C-level que quieren influir más, navegar complejidad y acelerar carrera.", + "geo": "Internacional (España/Europa/remoto) · fundadora con trayectoria global", + "languages": ["es", "en"], + "value_proposition": "Coaching ejecutivo ICF de alto nivel para líderes que operan en entornos internacionales complejos.", + "products_services": [ + "Executive coaching 1:1", + "Team / systemic coaching", + "Programas Gravitae Academy", + "Formación en liderazgo y comunicación persuasiva", + ], + "channel_handles": handles( + ig="https://www.instagram.com/gravitaeacademyofficial/", + li="https://www.linkedin.com/in/virginiamolet", + blog="https://gravitaeacademy.com/", + ), + "channels": channels( + ig="@gravitaeacademyofficial comunidad y marca academy", + li="Canal B2B principal (Virginia Molet PCC)", + blog="Web Gravitae + contenidos", + other="Marca personal: https://www.virginiamolet.com/ · también @virginiamoletcoach", + ), + "ads_usps": [ + "ICF PCC", + "+20 años experiencia internacional", + "Líderes de alto rendimiento", + "Coaching 1:1 y equipos", + ], + "ads_ctas": ["Agendar conversación", "Conocer Gravitae", "LinkedIn"], + "linkedin_themes": [ + "Liderazgo ejecutivo", + "Carrera e influencia", + "Coaching de equipos", + "Positive psychology aplicada", + ], + "linkedin_formats": ["artículo", "carrusel framework", "historia de cliente (anon)"], + "linkedin_cta": ["Mensaje", "Reservar discovery call"], + "blog_themes": ["Liderazgo", "Coaching", "Carrera"], + "blog_seo_keywords": [ + "executive coaching", + "coaching ejecutivo icf", + "leadership coaching spain", + ], + "claims_forbidden": ["Ascenso garantizado", "Aumento salarial asegurado"], + "required_disclaimers": ["Resultados dependen del compromiso del coachee y contexto organizacional"], + "pillars_rrss": ["liderazgo", "carrera", "bastidores-coaching", "comunidad"], + "b2b_offering": "Programas para empresas y equipos directivos.", + "b2c_offering": "Coaching ejecutivo individual (B2C premium / B2B2C).", + "usp_differentiators": "ICF + experiencia business internacional + Gravitae network.", + "current_priorities": "Leads discovery + autoridad LinkedIn/IG Gravitae.", + "jobs_to_be_done": ["Ganar influencia", "Navegar cambio", "Preparar siguiente rol"], + "buying_triggers": ["Nuevo rol", "Estancamiento", "Reorganización"], + "customer_frustrations": "Coaching superficial o formación genérica de liderazgo.", + "desired_transformation": "Liderar con claridad, presencia e impacto.", + "freeform_notes": "Web negocio https://gravitaeacademy.com/. Personal https://www.virginiamolet.com/. Meta page Gravitae Academy. IG @gravitaeacademyofficial.", +} + +PACKS["wine-advisors"] = { + "brand_name": "WineAdvisor", + "website": "https://www.wineadvisor.mx/", + "vertical": "SaaS / plataforma de gestión y carta digital de vinos (HORECA)", + "tagline": "La revolución en la gestión del vino", + "elevator_pitch": "WineAdvisor es la plataforma visual para que restaurantes, hoteles y retail gestionen inventario de vino, maridajes y ventas, con una experiencia digital para el comensal y control para el equipo.", + "brand_promise": "Impulsa el consumo de vino en tu negocio con una cava digital inteligente.", + "positioning": "Software integral de cava (inventario + experiencia + ventas) vs sola carta PDF/QR.", + "archetype": "El Creador + El Sabio (innovación + expertise vino)", + "mission": "Modernizar cómo los negocios venden y gestionan vino.", + "vision": "Estándar digital de gestión de vinos en hostelería (MX / ES / US).", + "values": ["Innovación", "Experiencia", "Datos", "Placer sin snobismo"], + "tone": "Experto, cercano, comercial B2B HORECA; vino sin snobismo.", + "personality": ["innovador", "cercano", "experto", "comercial"], + "voice_do": [ + "Hablar a dueños de restaurante y sommeliers", + "Beneficios: rotación, inventario, upselling", + "Mostrar interfaz visual", + ], + "voice_dont": [ + "Elitismo enológico excluyente", + "Venderlo solo como 'carta QR'", + "Confundir IG ES (@wineadvisor_es) con MX (@wine.advisor)", + ], + "messaging_pillars": [ + "Carta visual", + "Inventario y rotación", + "Maridajes", + "Ventas del equipo", + "Datos de consumo", + ], + "icp": "Restaurantes, hoteles, distribuidores y retail de vino que quieren digitalizar cava y aumentar ticket.", + "geo": "México (HQ) · España · EE.UU.", + "languages": ["es", "en"], + "value_proposition": "Plataforma visual de selección + inventario + analítica para vender más vino con menos fricción.", + "products_services": [ + "Carta / cava digital visual", + "Gestión de inventario", + "Maridajes y descubrimiento", + "Analítica de ventas", + "Implementación e inventario inicial", + ], + "differentiators": [ + "Interfaz visual única", + "No es solo QR: es gestión + ventas", + "Equipo de implementación", + ], + "proof_points": [ + "Producto wineadvisor.mx", + "LinkedIn Intelligent Restaurants de México", + "Presencia IG ES @wineadvisor_es y MX @wine.advisor", + ], + "pillars_rrss": ["producto", "maridaje", "horeca", "educativo-vino", "casos"], + "series_recurrentes": ["Tip cava", "Antes/después carta", "Dato de rotación"], + "formats_priority": ["reel", "carrusel", "post", "stories"], + "channel_handles": handles( + ig="https://www.instagram.com/wineadvisor_es/", + fb="https://www.facebook.com/WineAdvisor", + li="https://www.linkedin.com/company/intelligent-restaurants-de-mexico-s-a-de-c-v-/", + blog="https://www.wineadvisor.mx/", + ), + "channels": channels( + ig="ES @wineadvisor_es (también MX @wine.advisor)", + fb="WineAdvisor", + li="B2B HORECA principal", + blog="Producto y captación demo", + email="info@wineadvisor.mx", + ), + "ads_usps": [ + "Cava digital visual", + "Control de inventario", + "Más ventas de vino", + "Ideal HORECA", + ], + "ads_ctas": ["Solicitar demo", "Únete a WineAdvisor", "Hablar con un experto"], + "ads_objections": ["Ya tengo carta QR", "Mi equipo no lo usará", "Precio SaaS"], + "ads_angles": ["De carta PDF a experiencia", "Rotación de etiquetas", "Upselling en sala"], + "linkedin_themes": [ + "Hospitality tech", + "Gestión de cava", + "Experiencia del comensal", + "Datos de consumo", + ], + "linkedin_formats": ["demo UI", "caso restaurante", "carrusel beneficios"], + "linkedin_cta": ["Pedir demo", "Contactar"], + "blog_themes": ["Gestión de vino", "Tendencias HORECA", "Maridaje"], + "blog_seo_keywords": [ + "carta de vinos digital", + "software inventario vino restaurante", + "wineadvisor", + ], + "b2b_offering": "SaaS + implementación para centros de consumo de vino.", + "usp_differentiators": "Experiencia visual + inventario + analítica en una plataforma.", + "current_priorities": "Demos HORECA ES/MX y notoriedad de producto.", + "jobs_to_be_done": ["Vender más vino", "Controlar stock", "Formar al equipo de sala"], + "buying_triggers": ["Carta desactualizada", "Merma/inventario caótico", "Nueva apertura"], + "customer_frustrations": "Cartas estáticas y cero datos de qué se vende.", + "desired_transformation": "Cava viva que vende sola y se controla fácil.", + "freeform_notes": "Web canónica https://www.wineadvisor.mx/. IG campaña ES: @wineadvisor_es. LinkedIn company/intelligent-restaurants-de-mexico-s-a-de-c-v-.", +} + +# Campos de kit visual que no pisamos si ya existen +PRESERVE_IF_NONEMPTY = { + "colors", + "fonts_kit", + "kit_assets", + "colors_primary", + "colors_secondary", + "fonts", + "source_excerpts", +} + + +def is_filled(v: Any) -> bool: + if v is None: + return False + if isinstance(v, str): + return bool(v.strip()) + if isinstance(v, list): + return len(v) > 0 + if isinstance(v, dict): + return any(is_filled(x) for x in v.values()) + return True + + +def looks_like_bad_handle(s: str) -> bool: + """Handles que metimos mal (IDs numéricos Meta como URL).""" + if not s: + return True + if re.search(r"instagram\.com/\d{5,}", s): + return True + if re.search(r"facebook\.com/\d{5,}$", s): + return True + return False + + +def merge_book(existing: dict, pack: dict) -> dict: + out = dict(existing or {}) + for k, v in pack.items(): + if k in ("channels", "channel_handles") and isinstance(v, dict): + base = dict(out.get(k) or {}) + for hk, hv in v.items(): + if not hv: + continue + cur = str(base.get(hk) or "") + if not cur or looks_like_bad_handle(cur) or k == "channel_handles": + base[hk] = hv + elif not is_filled(cur): + base[hk] = hv + else: + # pack wins for handles always; for channel roles overwrite if pack set + if k == "channels": + base[hk] = hv + out[k] = base + continue + if k in PRESERVE_IF_NONEMPTY and is_filled(out.get(k)) and not is_filled(v): + continue + if v is None: + continue + if isinstance(v, list) and len(v) == 0 and is_filled(out.get(k)): + continue + if isinstance(v, str) and v.strip() == "" and is_filled(out.get(k)): + # no borrar con vacío + continue + out[k] = v + # website siempre del pack si viene + if pack.get("website"): + out["website"] = pack["website"] + if pack.get("brand_name"): + out["brand_name"] = pack["brand_name"] + out["version"] = 2 + return out + + +def score_key_fields(book: dict) -> tuple[int, list[str]]: + keys = [ + "website", + "tagline", + "elevator_pitch", + "tone", + "mission", + "value_proposition", + "icp", + "products_services", + "channel_handles", + "ads_usps", + "ads_ctas", + "linkedin_themes", + "blog_themes", + "pillars_rrss", + ] + miss = [k for k in keys if not is_filled(book.get(k))] + # channel linkedin specifically + ch = book.get("channel_handles") or {} + if not is_filled(ch.get("linkedin")): + miss.append("linkedin_handle") + if not is_filled(ch.get("instagram")): + miss.append("instagram_handle") + filled = len(keys) - len([k for k in miss if k in keys]) + pct = round(100 * filled / len(keys)) + return pct, miss + + +def main() -> None: + tenants = get( + "tenants?agency_followed=neq.false&select=id,slug,name,default_landing_url&order=slug" + ) + print(f"Active tenants: {len(tenants)} · packs: {len(PACKS)}") + missing_packs = [t["slug"] for t in tenants if t["slug"] not in PACKS] + if missing_packs: + print("WARNING no pack for:", missing_packs) + + for t in tenants: + slug = t["slug"] + pack = PACKS.get(slug) + if not pack: + print(f"[skip] {slug}") + continue + docs = get( + f"documents?tenant_id=eq.{t['id']}&type=eq.brandbook&select=id,content&order=updated_at.desc&limit=1" + ) + if not docs: + print(f"[skip] {slug} sin brandbook doc") + continue + content = docs[0]["content"] + if isinstance(content, str): + content = json.loads(content) + before, miss_b = score_key_fields(content) + merged = merge_book(content, pack) + after, miss_a = score_key_fields(merged) + patch(f"documents?id=eq.{docs[0]['id']}", {"content": merged}) + # sync tenant landing + landing = pack.get("website") + if landing and t.get("default_landing_url") != landing: + patch(f"tenants?id=eq.{t['id']}", {"default_landing_url": landing}) + print( + f"[ok] {slug:22} score {before}%→{after}% web={pack.get('website')} still_miss={miss_a[:6]}" + ) + + print("DONE") + + +if __name__ == "__main__": + main() diff --git a/scripts/enrich_thin_dna.ts b/scripts/enrich_thin_dna.ts new file mode 100644 index 0000000..fe64a69 --- /dev/null +++ b/scripts/enrich_thin_dna.ts @@ -0,0 +1,46 @@ +/** + * Force-seed enrich for DNA-thin brandbooks after calendar-generate-brand updates. + */ +import { config } from 'dotenv'; +import { resolve } from 'path'; +config({ path: resolve(process.cwd(), '.env.local') }); + +import { enrichTenantBrandbook } from '../src/lib/brandbook-enrich'; + +const THIN = [ + 'delgado-abogados', + 'equinsa-parking', + 'fbm-abogados', + 'la-freseria', + 'virginia-molet', + 'wine-advisors', +]; + +async function main() { + const url = process.env.NEXT_PUBLIC_SUPABASE_URL!; + const key = process.env.SUPABASE_SERVICE_ROLE_KEY!; + const h = { + apikey: key, + Authorization: `Bearer ${key}`, + }; + const tenants = (await fetch( + `${url}/rest/v1/tenants?slug=in.(${THIN.join(',')})&select=id,slug`, + { headers: h } + ).then((r) => r.json())) as { id: string; slug: string }[]; + + for (const t of tenants) { + const r = await enrichTenantBrandbook({ tenantId: t.id, forceSeed: true }); + console.log( + t.slug, + `${r.completenessBefore}→${r.completenessAfter}`, + 'filled', + (r.filledFields || []).slice(0, 10).join(',') || '-', + r.warning || '' + ); + } +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/scripts/ensure_ecp_tsp_drive.ts b/scripts/ensure_ecp_tsp_drive.ts new file mode 100644 index 0000000..4113d60 --- /dev/null +++ b/scripts/ensure_ecp_tsp_drive.ts @@ -0,0 +1,132 @@ +/** + * Create client Drive roots for ECP + TSP under 03-Clientes if missing, + * link integrations + tenants.drive_folder_id, ensure Ads/RRSS creatives folders, + * re-enrich brandbooks. + * + * npx tsx scripts/ensure_ecp_tsp_drive.ts + */ +import { config } from 'dotenv'; +import { resolve } from 'path'; +config({ path: resolve(process.cwd(), '.env.local') }); + +import { enrichTenantBrandbook } from '../src/lib/brandbook-enrich'; +import { ensureCreativesDriveFolders } from '../src/lib/creatives-drive'; +import { + ensureChildFolder, + findClientesRootFolder, + listClientFoldersUnderClientes, +} from '../src/lib/google-drive'; + +const url = process.env.NEXT_PUBLIC_SUPABASE_URL!; +const key = process.env.SUPABASE_SERVICE_ROLE_KEY!; +const h = { + apikey: key, + Authorization: `Bearer ${key}`, + 'Content-Type': 'application/json', + Prefer: 'return=minimal', +}; + +const TARGETS: { slug: string; folderName: string }[] = [ + { slug: 'ecp', folderName: '50-ECP Escuela de Cuidadores' }, + { slug: 'tsp', folderName: '51-TSP Talento Scholar Program' }, +]; + +async function main() { + const listed = await listClientFoldersUnderClientes(); + const root = listed.clientesRoot || (await findClientesRootFolder()); + if (!root?.id) throw new Error('No se encontró carpeta Clientes en Drive'); + console.log('Clientes root', root.name, root.id); + + const folders = listed.folders; + + for (const t of TARGETS) { + const tr = await fetch( + `${url}/rest/v1/tenants?slug=eq.${t.slug}&select=id,slug,name`, + { headers: h } + ); + const tenant = (await tr.json())[0] as + | { id: string; slug: string; name: string } + | undefined; + if (!tenant) { + console.log(`[skip] ${t.slug} no existe`); + continue; + } + + let folder = folders.find((f) => + t.slug === 'ecp' + ? /ecp|cuidadores/i.test(f.name) && !/navidad/i.test(f.name) + : /tsp|scholar/i.test(f.name) + ); + + if (!folder) { + const id = await ensureChildFolder(root.id, t.folderName); + folder = { id, name: t.folderName }; + console.log(`[create] ${t.folderName} → ${id}`); + } else { + console.log(`[exist] ${folder.name} → ${folder.id}`); + } + + await fetch( + `${url}/rest/v1/integrations?tenant_id=eq.${tenant.id}&provider=eq.google_drive`, + { + method: 'PATCH', + headers: h, + body: JSON.stringify({ + status: 'connected', + enabled: true, + external_account_id: folder.id, + label: folder.name, + updated_at: new Date().toISOString(), + }), + } + ); + + // Upsert if no google_drive row + const check = await fetch( + `${url}/rest/v1/integrations?tenant_id=eq.${tenant.id}&provider=eq.google_drive&select=id`, + { headers: h } + ); + const rows = (await check.json()) as { id: string }[]; + if (!rows.length) { + await fetch(`${url}/rest/v1/integrations`, { + method: 'POST', + headers: h, + body: JSON.stringify({ + tenant_id: tenant.id, + provider: 'google_drive', + status: 'connected', + enabled: true, + external_account_id: folder.id, + label: folder.name, + }), + }); + } + + await fetch(`${url}/rest/v1/tenants?id=eq.${tenant.id}`, { + method: 'PATCH', + headers: h, + body: JSON.stringify({ drive_folder_id: folder.id }), + }); + + const creatives = await ensureCreativesDriveFolders(tenant.id, t.slug); + console.log( + `[creatives] ${t.slug}`, + creatives.ok ? 'Ads+RRSS ok' : creatives.warning + ); + + const enrich = await enrichTenantBrandbook({ + tenantId: tenant.id, + forceSeed: true, + }); + console.log( + `[enrich] ${t.slug} ok=${enrich.ok} score=${enrich.completenessAfter} warn=${enrich.warning || '-'}` + ); + } + + console.log('Done'); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/scripts/fix_brand_gaps.ts b/scripts/fix_brand_gaps.ts new file mode 100644 index 0000000..00ba8e6 --- /dev/null +++ b/scripts/fix_brand_gaps.ts @@ -0,0 +1,232 @@ +/** + * Fix remaining gaps for TYD / TSP / ECP / DTATF: + * - Rematch Drive folders (esp. ECP, TSP) + * - Ensure brandbooks exist + * - Seed disconnected integration shells for new tenants + * - Ensure Creatividades-Ads/RRSS folders + * + * npx tsx scripts/fix_brand_gaps.ts + */ +import { config } from 'dotenv'; +import { resolve } from 'path'; +config({ path: resolve(process.cwd(), '.env.local') }); + +import { enrichTenantBrandbook } from '../src/lib/brandbook-enrich'; +import { ensureCreativesDriveFolders } from '../src/lib/creatives-drive'; +import { + applyMatches, + listClientFoldersUnderClientes, + matchFoldersToTenants, + scoreFolderToTenant, +} from '../src/lib/google-drive'; +import { + BRAND_DISPLAY_NAMES, + brandSiblingNote, +} from '../src/lib/tenant-brand-aliases'; + +const url = process.env.NEXT_PUBLIC_SUPABASE_URL!; +const key = process.env.SUPABASE_SERVICE_ROLE_KEY!; +const h = { + apikey: key, + Authorization: `Bearer ${key}`, + 'Content-Type': 'application/json', + Prefer: 'return=representation', +}; + +const FOCUS = ['tyd', 'tsp', 'ecp', 'dtatf'] as const; + +const PROVIDERS = [ + 'meta_ads', + 'facebook', + 'instagram', + 'google_ads', + 'google_drive', + 'ga4', + 'search_console', + 'gohighlevel', + 'clientify', + 'linkedin', +] as const; + +async function rest(path: string, init?: RequestInit): Promise { + const r = await fetch(`${url}/rest/v1/${path}`, { + ...init, + headers: { ...h, ...(init?.headers || {}) }, + }); + const text = await r.text(); + if (!r.ok) throw new Error(`${path} ${r.status}: ${text.slice(0, 400)}`); + return text ? (JSON.parse(text) as T) : ([] as T); +} + +async function ensureIntegrationShells(tenantId: string) { + const existing = await rest<{ provider: string }[]>( + `integrations?tenant_id=eq.${tenantId}&select=provider` + ); + const have = new Set(existing.map((r) => r.provider)); + for (const provider of PROVIDERS) { + if (have.has(provider)) continue; + await rest('integrations', { + method: 'POST', + headers: { Prefer: 'resolution=ignore-duplicates,return=minimal' }, + body: JSON.stringify({ + tenant_id: tenantId, + provider, + status: 'disconnected', + enabled: false, + label: provider, + }), + }).catch(() => undefined); + } +} + +async function main() { + const tenants = await rest< + { id: string; slug: string; name: string; drive_folder_id?: string | null }[] + >( + `tenants?slug=in.(${FOCUS.join(',')})&select=id,slug,name,drive_folder_id` + ); + const bySlug = Object.fromEntries(tenants.map((t) => [t.slug, t])); + console.log( + 'focus tenants', + tenants.map((t) => t.slug).join(', ') + ); + + for (const t of tenants) { + await ensureIntegrationShells(t.id); + console.log(`[shells] ${t.slug}`); + } + + // Drive rematch under Clientes + try { + const { folders, clientesRoot } = await listClientFoldersUnderClientes(); + console.log( + `Drive root=${clientesRoot?.name || '?'} folders=${folders.length}` + ); + + // Manual preferred matches by name heuristics + const prefer: Record = { + ecp: /^(?:\d+[\.\-\s_]*)?(?:ecp|escuela\s*de\s*cuidadores|cuidadores)/i, + tsp: /^(?:\d+[\.\-\s_]*)?(?:tsp|talento\s*scholar|scholar)/i, + tyd: /^(?:\d+[\.\-\s_]*)?(?:06[\.\-\s_]*)?talento\s*y\s*deporte/i, + dtatf: /^(?:\d+[\.\-\s_]*)?(?:08[\.\-\s_]*)?(?:detuatu|de\s*tu\s*a\s*tu)/i, + }; + + for (const slug of FOCUS) { + const t = bySlug[slug]; + if (!t) continue; + // skip Navidad-like noise + const candidates = folders + .map((f) => ({ + f, + score: prefer[slug]?.test(f.name) + ? 100 + : scoreFolderToTenant(f.name, t.name, t.slug), + })) + .filter((x) => x.score >= 70 && !/navidad/i.test(x.f.name)) + .sort((a, b) => b.score - a.score); + + console.log( + `[drive candidates ${slug}]`, + candidates.slice(0, 5).map((c) => `${c.score}:${c.f.name}`) + ); + + const best = candidates[0]; + if (!best) continue; + + await rest( + `integrations?tenant_id=eq.${t.id}&provider=eq.google_drive`, + { + method: 'PATCH', + headers: { Prefer: 'return=minimal' }, + body: JSON.stringify({ + status: 'connected', + enabled: true, + external_account_id: best.f.id, + label: best.f.name, + updated_at: new Date().toISOString(), + }), + } + ); + await rest(`tenants?id=eq.${t.id}`, { + method: 'PATCH', + headers: { Prefer: 'return=minimal' }, + body: JSON.stringify({ drive_folder_id: best.f.id }), + }); + console.log(`[drive] ${slug} ← ${best.f.name} (${best.f.id})`); + } + + // Also run generic matcher for any still missing + const still = await rest< + { id: string; slug: string; name: string; drive_folder_id?: string | null }[] + >( + `tenants?slug=in.(${FOCUS.join(',')})&select=id,slug,name,drive_folder_id` + ); + const need = still.filter((t) => !t.drive_folder_id); + if (need.length) { + const matches = await matchFoldersToTenants(need, folders); + const good = matches.filter( + (m) => + m.status === 'matched' && + m.folderId && + !/navidad/i.test(m.folderName || '') + ); + if (good.length) { + await applyMatches(good); + for (const m of good) { + await rest(`tenants?id=eq.${m.tenantId}`, { + method: 'PATCH', + headers: { Prefer: 'return=minimal' }, + body: JSON.stringify({ drive_folder_id: m.folderId }), + }); + console.log(`[drive match] ${m.tenantSlug} ← ${m.folderName}`); + } + } + } + } catch (e) { + console.error('Drive rematch failed', e); + } + + // Enrich + ensure folders + for (const slug of FOCUS) { + const t = bySlug[slug]; + if (!t) continue; + await rest(`tenants?id=eq.${t.id}`, { + method: 'PATCH', + headers: { Prefer: 'return=minimal' }, + body: JSON.stringify({ + name: BRAND_DISPLAY_NAMES[slug], + agency_notes: [ + brandSiblingNote(slug), + `Código canónico: ${slug.toUpperCase()}.`, + ] + .filter(Boolean) + .join(' '), + }), + }); + const r = await enrichTenantBrandbook({ + tenantId: t.id, + forceSeed: true, + }); + const drive = await ensureCreativesDriveFolders(t.id, slug); + console.log( + `[enrich] ${slug} created=${r.created} ${r.completenessBefore}→${r.completenessAfter} sources=${r.sources.join('+')} drive=${drive.ok ? 'ok' : drive.warning}` + ); + } + + // Verify brandbooks + for (const slug of FOCUS) { + const t = bySlug[slug]; + if (!t) continue; + const bb = await rest<{ id: string }[]>( + `documents?tenant_id=eq.${t.id}&type=eq.brandbook&select=id&limit=1` + ); + console.log(`[verify] ${slug} brandbook=${bb[0]?.id || 'MISSING'}`); + } + + console.log('Done'); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/scripts/heal_agency_roles.ts b/scripts/heal_agency_roles.ts new file mode 100644 index 0000000..c81c5be --- /dev/null +++ b/scripts/heal_agency_roles.ts @@ -0,0 +1,96 @@ +/** + * Asegura app_metadata.role=admin (o team) para operadores @4ventures.es. + * npx tsx scripts/heal_agency_roles.ts + */ +import { config } from 'dotenv'; +import { resolve } from 'path'; +import { createClient } from '@supabase/supabase-js'; + +config({ path: resolve(process.cwd(), '.env.local') }); + +const HUB_TENANT = '0b3c5fc0-6901-4caf-b26a-b6a0201d8541'; +const COMPANY = '4Ventures'; +const SLUG = '4ventures-hub'; + +const FORCE_ADMIN = [ + 'marketing@4ventures.es', + 'marcos@4ventures.es', + 'belen@4ventures.es', + 'rebecca@4ventures.es', + 'victor@4ventures.es', + 'agencia@4ventures.es', + 'ops@4ventures.es', +]; + +async function main() { + const url = process.env.NEXT_PUBLIC_SUPABASE_URL?.trim(); + const key = process.env.SUPABASE_SERVICE_ROLE_KEY?.trim(); + if (!url || !key) throw new Error('Faltan env Supabase'); + + const admin = createClient(url, key, { + auth: { persistSession: false, autoRefreshToken: false }, + }); + + const emails = new Set(FORCE_ADMIN.map((e) => e.toLowerCase())); + let page = 1; + let fixed = 0; + let scanned = 0; + + for (;;) { + const { data, error } = await admin.auth.admin.listUsers({ + page, + perPage: 200, + }); + if (error) throw error; + const users = data.users || []; + if (!users.length) break; + + for (const u of users) { + scanned += 1; + const email = (u.email || '').toLowerCase(); + if (!email.endsWith('@4ventures.es') && !emails.has(email)) continue; + + const appRole = String(u.app_metadata?.role || ''); + if (appRole === 'admin' || appRole === 'team') { + // Ya OK; repara tenant/company si faltan + if (u.app_metadata?.tenant_id && u.app_metadata?.company) continue; + } + if (appRole === 'client') { + console.log(`skip client explícito: ${email}`); + continue; + } + + const role = emails.has(email) ? 'admin' : 'team'; + const { error: updErr } = await admin.auth.admin.updateUserById(u.id, { + app_metadata: { + ...(u.app_metadata || {}), + role, + tenant_id: u.app_metadata?.tenant_id || HUB_TENANT, + company: u.app_metadata?.company || COMPANY, + slug: u.app_metadata?.slug || SLUG, + }, + user_metadata: { + ...(u.user_metadata || {}), + role, + company: COMPANY, + }, + }); + if (updErr) { + console.error(`FAIL ${email}:`, updErr.message); + } else { + fixed += 1; + console.log(`OK ${email} → app_metadata.role=${role}`); + } + } + + if (users.length < 200) break; + page += 1; + } + + console.log(`Scanned ${scanned}, fixed ${fixed}`); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/scripts/import_drive_calendars_batch.ts b/scripts/import_drive_calendars_batch.ts index f04a217..04c276b 100644 --- a/scripts/import_drive_calendars_batch.ts +++ b/scripts/import_drive_calendars_batch.ts @@ -97,7 +97,7 @@ const JOBS: ClientJob[] = [ ], }, { - slug: 'talento-y-deporte', + slug: 'tyd', brand: 'Talento y Deporte', file: `${ROOT}/TYD/CALENDARIO TYD.xlsx`, sheets: { diff --git a/scripts/import_drive_calendars_live.py b/scripts/import_drive_calendars_live.py index d34d8b6..0bdbdbe 100644 --- a/scripts/import_drive_calendars_live.py +++ b/scripts/import_drive_calendars_live.py @@ -50,7 +50,7 @@ DRIVE_SHEETS = { # Single "Hoja 1" sheet — ops calendar for Oct 2026 (Jueves 1 also matches enero). "force_sheet_months": {"Hoja 1": "2026-10"}, }, - "talento-y-deporte": { + "tyd": { "brand": "Talento y Deporte", "file_id": "1HSbJAfjlBDdpHJubgxhT322UUZVJ2zlQHaPkO0HtevA", }, diff --git a/scripts/remediate_brand_truth.py b/scripts/remediate_brand_truth.py new file mode 100644 index 0000000..64ca5eb --- /dev/null +++ b/scripts/remediate_brand_truth.py @@ -0,0 +1,632 @@ +#!/usr/bin/env python3 +""" +Remedia brandbooks: elimina kits inventados y aplica solo señales +verificadas desde webs oficiales (+ notas de procedencia). +""" +from __future__ import annotations + +import json +import random +import time +import urllib.request +from pathlib import Path +from typing import Any + +ROOT = Path(__file__).resolve().parents[1] + + +def env(k: str) -> str: + for line in (ROOT / ".env.local").read_text().splitlines(): + if line.startswith(k + "="): + return line.split("=", 1)[1].strip().strip('"').strip("'") + return "" + + +URL = env("NEXT_PUBLIC_SUPABASE_URL") +KEY = env("SUPABASE_SERVICE_ROLE_KEY") +HR = {"apikey": KEY, "Authorization": f"Bearer {KEY}"} +HW = {**HR, "Content-Type": "application/json", "Prefer": "return=minimal"} + + +def get(path: str) -> Any: + req = urllib.request.Request(f"{URL}/rest/v1/{path}", headers=HR) + return json.loads(urllib.request.urlopen(req).read()) + + +def patch(path: str, body: dict) -> None: + req = urllib.request.Request( + f"{URL}/rest/v1/{path}", + data=json.dumps(body).encode(), + headers=HW, + method="PATCH", + ) + urllib.request.urlopen(req) + + +def nid(p: str) -> str: + return f"{p}_{int(time.time() * 1000)}_{random.randint(1000, 9999)}" + + +def color(name: str, hex_: str, role: str, usage: str) -> dict: + return { + "id": nid("col"), + "name": name, + "hex": hex_.upper() if hex_.startswith("#") else hex_, + "role": role, + "usage": usage, + } + + +def font(family: str, role: str, source: str, notes: str) -> dict: + return { + "id": nid("font"), + "family": family, + "role": role, + "weights": "", + "source": source, + "url_or_notes": notes, + } + + +# Solo colores con evidencia fuerte en HTML/CSS de la web oficial. +# Si no hay evidencia clara → colors = [] (mejor vacío que inventado). +VERIFIED_VISUAL: dict[str, dict[str, Any]] = { + "4ventures-hub": { + "colors": [ + color( + "Naranja marca (web)", + "#E05200", + "primary", + "Frecuencia alta en CSS de https://4ventures.es/", + ), + ], + "fonts_kit": [ + font( + "DM Sans", + "headline", + "google", + "font-family detectado en HTML de 4ventures.es", + ), + font( + "Cormorant Garamond", + "display", + "google", + "font-family detectado en HTML de 4ventures.es", + ), + ], + "source": "HTML/CSS https://4ventures.es/", + }, + "agua-miia": { + "colors": [ + color( + "Azul primario", + "#0085F2", + "primary", + "CSS var marca en https://aguamiia.es/", + ), + color( + "Azul corporativo", + "#2071CF", + "secondary", + "CSS var / uso frecuente en aguamiia.es", + ), + color( + "Azul oscuro", + "#0F4F9E", + "neutral", + "CSS var en aguamiia.es", + ), + color( + "Verde acento", + "#2E9B76", + "accent", + "CSS var en aguamiia.es", + ), + ], + "fonts_kit": [ + font( + "Urbanist", + "headline", + "google", + "font-family dominante en HTML de aguamiia.es", + ), + font( + "Nunito", + "body", + "google", + "font-family secundario en HTML de aguamiia.es", + ), + ], + "source": "CSS variables + font-family https://aguamiia.es/", + }, + "bps": { + # BPS ya tenía kit; reforzamos con theme-color oficial + "colors": [ + color( + "BPS Blue", + "#0051AA", + "primary", + "theme-color meta + uso en https://blueparrotschool.com/", + ), + color( + "Accent amarillo", + "#FDC526", + "accent", + "Hex frecuente en CSS de blueparrotschool.com", + ), + color( + "Ink", + "#201E22", + "neutral", + "Hex frecuente en CSS de blueparrotschool.com", + ), + ], + "fonts_kit": [ + font( + "Figtree", + "headline", + "google", + "Referenciado en HTML blueparrotschool.com", + ), + font( + "Jost", + "body", + "google", + "Referenciado en HTML blueparrotschool.com", + ), + ], + "source": "theme-color + CSS https://blueparrotschool.com/", + "merge_colors_if_existing": True, + }, + "brava-dermoestetica": { + "colors": [ + color( + "Rosa/brandy clínica", + "#7A3F4D", + "primary", + "Hex dominante (69×) en https://www.bravadermoestetica.com/", + ), + color( + "Rosa oscuro", + "#61323D", + "secondary", + "Hex frecuente en bravadermoestetica.com", + ), + color( + "Fondo cálido", + "#F8F5F2", + "neutral", + "Hex de fondo en bravadermoestetica.com", + ), + ], + "fonts_kit": [ + font( + "Playfair Display", + "headline", + "google", + "font-family en HTML bravadermoestetica.com", + ), + font( + "Inter", + "body", + "google", + "font-family en HTML bravadermoestetica.com", + ), + font( + "Myriad Pro", + "other", + "adobe", + "font-family referenciado en HTML (licencia Adobe/sistema)", + ), + ], + "source": "HTML/CSS https://www.bravadermoestetica.com/", + }, + "delgado-abogados": { + "colors": [ + color( + "Azul enlace/marca", + "#1863DC", + "primary", + "Hex frecuente en https://abogadosdelgado.es/", + ), + color( + "Ink", + "#212121", + "neutral", + "Hex frecuente en abogadosdelgado.es", + ), + ], + "fonts_kit": [ + font( + "Inter", + "body", + "google", + "WP preset font-family Inter en abogadosdelgado.es", + ), + ], + "source": "HTML/CSS https://abogadosdelgado.es/ (parcial; ampliar con brand kit PDF)", + }, + "dtatf": { + "colors": [ + color( + "Amarillo De Tú a Tú", + "#F6A800", + "primary", + "Hex en CSS/HTML https://detuatuformacion.es/", + ), + color( + "Azul oscuro De Tú a Tú", + "#273645", + "secondary", + "Hex en CSS/HTML detuatuformacion.es (+ #253645)", + ), + ], + "fonts_kit": [ + font( + "Poppins-DeTUaTU", + "headline", + "file", + "Familia custom referenciada en detuatuformacion.es (archivo/fuente propia)", + ), + ], + "source": "HTML/CSS https://detuatuformacion.es/", + }, + "ecp": { + "colors": [ + color( + "Amarillo ECP", + "#F7A801", + "primary", + "Hex dominante en https://escueladecuidadoresprofesionales.com/", + ), + color( + "Azul", + "#1863DC", + "secondary", + "Hex frecuente en web ECP", + ), + color( + "Ink", + "#212121", + "neutral", + "Hex frecuente en web ECP", + ), + ], + "fonts_kit": [ + font( + "Raleway", + "headline", + "google", + "font-family dominante en web ECP", + ), + font( + "Open Sans", + "body", + "google", + "font-family secundario en web ECP", + ), + ], + "source": "HTML/CSS https://escueladecuidadoresprofesionales.com/", + }, + "equinsa-parking": { + "colors": [ + color( + "Navy Equinsa", + "#000028", + "primary", + "Hex en https://equinsaparking.com/", + ), + color( + "Azul", + "#003388", + "secondary", + "Hex en equinsaparking.com", + ), + color( + "Azul enlace", + "#046BD2", + "accent", + "CSS var / hex en equinsaparking.com", + ), + ], + "fonts_kit": [ + font( + "Roboto", + "body", + "google", + "font-family dominante en equinsaparking.com", + ), + ], + "source": "HTML/CSS https://equinsaparking.com/", + }, + "fbm-abogados": { + "colors": [ + color( + "Navy FBM", + "#04243C", + "primary", + "Hex dominante (75×) en https://fbmasociados.com/", + ), + ], + "fonts_kit": [], # tahoma genérico — no afirmar kit de marca + "source": "HTML/CSS https://fbmasociados.com/ (color). Tipografía: pendiente brand kit.", + }, + "halal-safety": { + "colors": [], # scrape débil / ruido WP + "fonts_kit": [ + font( + "Manrope", + "body", + "google", + "Referenciado en HTML halalsafety.com", + ), + ], + "source": "Tipografía HTML https://www.halalsafety.com/. Colores: insuficientes en scrape — vacíos a propósito.", + }, + "la-freseria": { + "colors": [], # el scrape pilló azules de template Framer, NO son marca fresa + "fonts_kit": [ + font( + "La Fresería", + "display", + "file", + "Familia custom 'La Fresería' referenciada en lafreseria.es (archivo propio)", + ), + ], + "source": "Fuentes custom en https://lafreseria.es/. Colores NO aplicados: el HTML mezcla tokens Framer (#046bd2) no confiables como marca.", + }, + "noberplast": { + "colors": [ + color( + "Azul Noberplast", + "#0072AB", + "primary", + "Hex en https://noberplast.com/", + ), + color( + "Verde", + "#06A161", + "secondary", + "Hex en noberplast.com", + ), + color( + "Rojo acento", + "#DB062C", + "accent", + "Hex en noberplast.com", + ), + ], + "fonts_kit": [], + "source": "Hex en https://noberplast.com/. Tipografía: no detectada con claridad.", + }, + "tsp": { + "colors": [ + color( + "Navy Scholar", + "#161B2F", + "primary", + "Hex frecuente en https://talentoscholarprogram.com/", + ), + color( + "Azul claro", + "#A4CAEF", + "secondary", + "Hex frecuente en talentoscholarprogram.com", + ), + color( + "Rojo acento", + "#A40406", + "accent", + "Hex en talentoscholarprogram.com", + ), + ], + "fonts_kit": [ + font( + "Poppins", + "body", + "google", + "font-family dominante en talentoscholarprogram.com", + ), + font( + "Fraunces", + "display", + "google", + "font-family en talentoscholarprogram.com", + ), + ], + "source": "HTML/CSS https://talentoscholarprogram.com/", + }, + "tyd": { + "colors": [ + color( + "Rojo TYD", + "#E92428", + "primary", + "Hex dominante en https://talentoydeporte.com/", + ), + color( + "Azul TYD", + "#004885", + "secondary", + "Hex en talentoydeporte.com", + ), + ], + "fonts_kit": [ + font( + "Montserrat", + "headline", + "google", + "font-family en talentoydeporte.com", + ), + font( + "Oswald", + "display", + "google", + "font-family referenciado en talentoydeporte.com", + ), + ], + "source": "HTML/CSS https://talentoydeporte.com/", + }, + "virginia-molet": { + "colors": [ + color( + "Azul Gravitae", + "#004271", + "primary", + "Hex frecuente en https://gravitaeacademy.com/", + ), + color( + "Azul enlace", + "#1863DC", + "secondary", + "Hex frecuente en gravitaeacademy.com (posible token WP; contrastar con kit)", + ), + ], + "fonts_kit": [ + font( + "quincy-cf", + "display", + "adobe", + "font-family 'quincy-cf' en gravitaeacademy.com", + ), + font( + "Inter", + "body", + "google", + "font-family Inter en gravitaeacademy.com", + ), + ], + "source": "HTML/CSS https://gravitaeacademy.com/ (revisar azul #1863DC vs kit oficial)", + }, + "wine-advisors": { + "colors": [ + color( + "Rojo WineAdvisor", + "#CC0100", + "primary", + "Hex detectado en https://www.wineadvisor.mx/", + ), + ], + "fonts_kit": [], + "source": "Hex limitado en https://www.wineadvisor.mx/. Tipografía: no detectada.", + }, +} + +PROVENANCE_HEADER = ( + "[PROCEDENCIA 2026-10-15] " + "Webs/handles/LinkedIn: contrastados con sitio oficial + Meta business_discovery + páginas LinkedIn. " + "Colores/fuentes: solo si aparecen en HTML/CSS de la web (ver visual_notes). " + "Textos de posicionamiento/tono/ICP: síntesis de brand manager a partir del copy público de la web " + "(no son citas literales del cliente ni de un PDF de marca). " + "Si un campo no tiene evidencia → vacío. " +) + + +def parse(c: Any) -> dict: + if isinstance(c, str): + return json.loads(c) + return dict(c or {}) + + +def main() -> None: + tenants = get( + "tenants?agency_followed=neq.false&select=id,slug,name&order=slug" + ) + for t in tenants: + slug = t["slug"] + docs = get( + f"documents?tenant_id=eq.{t['id']}&type=eq.brandbook&select=id,content&order=updated_at.desc&limit=1" + ) + if not docs: + print("skip", slug) + continue + book = parse(docs[0]["content"]) + vis = VERIFIED_VISUAL.get(slug) + + if vis is None: + # no verified visual → clear invented kits + book["colors"] = [] + book["fonts_kit"] = [] + book["colors_primary"] = [] + book["colors_secondary"] = [] + book["fonts"] = [] + note = "Sin señales visuales verificables en scrape; kit vaciado." + else: + book["colors"] = vis.get("colors") or [] + book["fonts_kit"] = vis.get("fonts_kit") or [] + book["colors_primary"] = [ + c["hex"] for c in book["colors"] if c.get("role") == "primary" + ] + book["colors_secondary"] = [ + c["hex"] + for c in book["colors"] + if c.get("role") in ("secondary", "accent") + ] + book["fonts"] = [f["family"] for f in book["fonts_kit"]] + if book["fonts_kit"]: + heads = [f for f in book["fonts_kit"] if f["role"] in ("headline", "display")] + bodies = [f for f in book["fonts_kit"] if f["role"] == "body"] + if heads: + book["typography_headline"] = heads[0]["family"] + if bodies: + book["typography_body"] = bodies[0]["family"] + note = vis.get("source") or "" + + # Visual notes: honest provenance + book["visual_notes"] = ( + f"PROCEDENCIA VISUAL: {note}. " + "Los hex/fuentes listados salen del HTML/CSS público. " + "Sustituir por brand kit oficial (PDF/AI) cuando esté en Drive/Zona de marca. " + "NO usar paletas inventadas por IA." + ) + + # Provenance in freeform + prev = (book.get("freeform_notes") or "").strip() + # remove older provenance blocks to avoid duplication + lines = [ + ln + for ln in prev.split("\n") + if not ln.startswith("[PROCEDENCIA") + and "PROCEDENCIA VISUAL" not in ln + ] + book["freeform_notes"] = ( + PROVENANCE_HEADER + + (f"Visual: {note}. " if note else "") + + ("\n" + "\n".join(lines) if lines else "") + ).strip() + + # Clear weak invented claims that often aren't on-site + # Keep website/handles/products — those were verified in packs + # Soften proof_points that cite unverified stats + if slug == "la-freseria": + # don't keep invented franchise € figures as hard fact without date stamp + pps = book.get("proof_points") or [] + book["proof_points"] = [ + p + for p in pps + if "65.000" not in p and "EBITDA" not in p + ] + [ + "Claim web/franquicia: 'las fresas más famosas del mundo' (lafreseria.es)", + "IG oficial @lafreseriaoficial", + "LinkedIn company/la-freseria", + ] + + if slug == "equinsa-parking": + # keep qualitative; avoid conflicting installation counts + book["proof_points"] = [ + "Web: especializada en sistemas de gestión de aparcamientos (equinsaparking.com)", + "LinkedIn company/equinsa-parking", + "Sede comunicada: C. Primavera 16, Torrejón de Ardoz (web)", + "Contacto web: sat@equinsa.es / +34 618 18 89 24", + ] + + patch(f"documents?id=eq.{docs[0]['id']}", {"content": book}) + n_col = len(book.get("colors") or []) + n_font = len(book.get("fonts_kit") or []) + print(f"[ok] {slug:22} colors={n_col} fonts={n_font} · {note[:80]}") + + print("DONE — invented kits removed; verified web signals applied") + + +if __name__ == "__main__": + main() diff --git a/scripts/rename_brand_slugs.ts b/scripts/rename_brand_slugs.ts new file mode 100644 index 0000000..9ad1dd7 --- /dev/null +++ b/scripts/rename_brand_slugs.ts @@ -0,0 +1,135 @@ +/** + * Rename portfolio brand slugs to canonical codes: + * talento-y-deporte → tyd + * talento-scholar-program → tsp + * de-tu-a-tu-formacion → dtatf + * ecp → ecp (unchanged) + * + * npx tsx scripts/rename_brand_slugs.ts + */ +import { config } from 'dotenv'; +import { resolve } from 'path'; +config({ path: resolve(process.cwd(), '.env.local') }); + +import { enrichTenantBrandbook } from '../src/lib/brandbook-enrich'; +import { ensureCreativesDriveFolders } from '../src/lib/creatives-drive'; +import { + BRAND_DISPLAY_NAMES, + brandSiblingNote, +} from '../src/lib/tenant-brand-aliases'; + +const url = process.env.NEXT_PUBLIC_SUPABASE_URL!; +const key = process.env.SUPABASE_SERVICE_ROLE_KEY!; +const h = { + apikey: key, + Authorization: `Bearer ${key}`, + 'Content-Type': 'application/json', + Prefer: 'return=representation', +}; + +const RENAMES: { from: string; to: keyof typeof BRAND_DISPLAY_NAMES }[] = [ + { from: 'talento-y-deporte', to: 'tyd' }, + { from: 'talento-scholar-program', to: 'tsp' }, + { from: 'de-tu-a-tu-formacion', to: 'dtatf' }, +]; + +async function rest(path: string, init?: RequestInit): Promise { + const r = await fetch(`${url}/rest/v1/${path}`, { + ...init, + headers: { ...h, ...(init?.headers || {}) }, + }); + const text = await r.text(); + if (!r.ok) throw new Error(`${path} ${r.status}: ${text.slice(0, 400)}`); + return text ? (JSON.parse(text) as T) : ([] as T); +} + +async function main() { + const today = new Date().toISOString().slice(0, 10); + const tenants = await rest<{ id: string; slug: string; name: string; agency_notes?: string }[]>( + 'tenants?select=id,slug,name,agency_notes' + ); + const bySlug = Object.fromEntries(tenants.map((t) => [t.slug, t])); + + for (const { from, to } of RENAMES) { + const existingTo = bySlug[to]; + const fromRow = bySlug[from]; + if (existingTo && !fromRow) { + console.log(`[${to}] already canonical`); + continue; + } + if (!fromRow) { + console.log(`[skip] ${from} not found`); + continue; + } + if (existingTo && existingTo.id !== fromRow.id) { + throw new Error(`Conflict: both ${from} and ${to} exist`); + } + const sib = brandSiblingNote(to); + const notes = [ + `[${today}] Slug canónico: ${to} · ${BRAND_DISPLAY_NAMES[to]}.`, + sib, + fromRow.agency_notes || '', + ] + .filter(Boolean) + .join('\n') + .slice(0, 4000); + + await rest(`tenants?id=eq.${fromRow.id}`, { + method: 'PATCH', + headers: { Prefer: 'return=minimal' }, + body: JSON.stringify({ + slug: to, + name: BRAND_DISPLAY_NAMES[to], + agency_followed: true, + agency_notes: notes, + }), + }); + console.log(`[rename] ${from} → ${to}`); + bySlug[to] = { ...fromRow, slug: to, name: BRAND_DISPLAY_NAMES[to] }; + delete bySlug[from]; + } + + // Ensure ECP display name + const ecp = bySlug['ecp']; + if (ecp) { + await rest(`tenants?id=eq.${ecp.id}`, { + method: 'PATCH', + headers: { Prefer: 'return=minimal' }, + body: JSON.stringify({ + name: BRAND_DISPLAY_NAMES.ecp, + agency_followed: true, + agency_notes: [ + `[${today}] Marca canónica ECP (separada de DTATF).`, + brandSiblingNote('ecp'), + ecp.agency_notes || '', + ] + .filter(Boolean) + .join('\n') + .slice(0, 4000), + }), + }); + console.log('[ecp] display name refreshed'); + } + + for (const slug of ['tyd', 'tsp', 'ecp', 'dtatf'] as const) { + const t = bySlug[slug] || (await rest<{ id: string; slug: string }[]>( + `tenants?slug=eq.${slug}&select=id,slug&limit=1` + ))[0]; + if (!t) { + console.log(`[missing] ${slug}`); + continue; + } + const r = await enrichTenantBrandbook({ tenantId: t.id, forceSeed: true }); + const drive = await ensureCreativesDriveFolders(t.id, slug); + console.log( + `[enrich] ${slug} ${r.completenessBefore}→${r.completenessAfter} drive=${drive.ok ? 'ok' : drive.warning}` + ); + } + + console.log('Done'); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/scripts/split_tenants_and_fix.ts b/scripts/split_tenants_and_fix.ts new file mode 100644 index 0000000..021d37a --- /dev/null +++ b/scripts/split_tenants_and_fix.ts @@ -0,0 +1,272 @@ +/** + * Portfolio fix: + * - Menttum: confirm archived (no longer a client) + * - De Tú a Tú Formación ↔ ECP: separate as two active clients (move ECP social/ads) + * - Create Talento Scholar Program as sibling of Talento y Deporte + * - Sync tenants.drive_folder_id from google_drive integrations + * - Seed DNA + enrich brandbooks for affected + active portfolio + * + * npx tsx scripts/split_tenants_and_fix.ts + */ +import { config } from 'dotenv'; +import { resolve } from 'path'; +config({ path: resolve(process.cwd(), '.env.local') }); + +import { enrichTenantBrandbook } from '../src/lib/brandbook-enrich'; +import { ensureCreativesDriveFolders } from '../src/lib/creatives-drive'; + +const url = process.env.NEXT_PUBLIC_SUPABASE_URL!; +const key = process.env.SUPABASE_SERVICE_ROLE_KEY!; +const h = { + apikey: key, + Authorization: `Bearer ${key}`, + 'Content-Type': 'application/json', + Prefer: 'return=representation', +}; + +async function rest( + path: string, + init?: RequestInit +): Promise { + const r = await fetch(`${url}/rest/v1/${path}`, { + ...init, + headers: { ...h, ...(init?.headers || {}) }, + }); + const text = await r.text(); + if (!r.ok) throw new Error(`${path} ${r.status}: ${text.slice(0, 400)}`); + if (!text) return [] as T; + return JSON.parse(text) as T; +} + +async function patchTenant( + id: string, + patch: Record +): Promise { + await rest(`tenants?id=eq.${id}`, { + method: 'PATCH', + headers: { Prefer: 'return=minimal' }, + body: JSON.stringify(patch), + }); +} + +async function patchIntegration( + tenantId: string, + provider: string, + patch: Record +): Promise { + await rest( + `integrations?tenant_id=eq.${tenantId}&provider=eq.${provider}`, + { + method: 'PATCH', + headers: { Prefer: 'return=minimal' }, + body: JSON.stringify({ ...patch, updated_at: new Date().toISOString() }), + } + ); +} + +async function main() { + const today = new Date().toISOString().slice(0, 10); + + const tenants = await rest< + { + id: string; + slug: string; + name: string; + agency_followed?: boolean; + agency_notes?: string | null; + drive_folder_id?: string | null; + }[] + >('tenants?select=id,slug,name,agency_followed,agency_notes,drive_folder_id&order=name.asc'); + + const bySlug = Object.fromEntries(tenants.map((t) => [t.slug, t])); + + // ——— 1. Menttum: archived, not a client ——— + const menttum = bySlug['menttum']; + if (menttum) { + await patchTenant(menttum.id, { + agency_followed: false, + agency_notes: `[archivado ${today}] Ya no es cliente 4Ventures. No enriquecer / no sincronizar creatividades.\n${menttum.agency_notes || ''}`.slice( + 0, + 4000 + ), + creatives_enabled: false, + }); + for (const p of [ + 'meta_ads', + 'facebook', + 'instagram', + 'google_ads', + 'google_drive', + 'ga4', + 'search_console', + ]) { + await patchIntegration(menttum.id, p, { + status: 'disconnected', + enabled: false, + }).catch(() => undefined); + } + console.log('[menttum] archived + integrations disconnected'); + } + + // ——— 2. Separate De Tú a Tú vs ECP ——— + const detu = bySlug['de-tu-a-tu-formacion']; + const ecp = bySlug['ecp']; + if (!detu || !ecp) throw new Error('missing de-tu-a-tu-formacion or ecp'); + + // Activate ECP as its own client + await patchTenant(ecp.id, { + agency_followed: true, + name: 'ECP · Escuela de Cuidadores', + creatives_enabled: false, + default_landing_url: 'https://escueladecuidadoresprofesionales.com', + meta_ad_account_id: 'act_1320801442642004', + meta_page_id: '111175967913754', + meta_ig_user_id: '17841453624785447', + agency_notes: `[${today}] Cliente separado de De Tú a Tú Formación. Marca: Escuela de Cuidadores Profesionales (ECP). Meta act_1320801442642004 · IG @escuelaecp · web escueladecuidadoresprofesionales.com. Drive: buscar carpeta cliente ECP/Cuidadores (evitar «Navidad ecp» si no es raíz).`, + }); + + // Move ECP social/ads ownership onto ECP tenant (connect ECP rows) + await patchIntegration(ecp.id, 'meta_ads', { + status: 'connected', + enabled: true, + external_account_id: 'act_1320801442642004', + label: 'ECP', + }); + await patchIntegration(ecp.id, 'facebook', { + status: 'connected', + enabled: true, + external_account_id: '111175967913754', + label: 'Escuela de cuidadores profesionales', + }); + await patchIntegration(ecp.id, 'instagram', { + status: 'connected', + enabled: true, + external_account_id: '17841453624785447', + label: '@escuelaecp', + }); + await patchIntegration(ecp.id, 'ga4', { + status: 'connected', + enabled: true, + external_account_id: 'G-82HW868X9L', + label: 'Web escueladecuidadoresprofesionales.com - GA4', + }); + + // Detach ECP assets from De Tú a Tú + await patchIntegration(detu.id, 'meta_ads', { + status: 'disconnected', + enabled: false, + external_account_id: 'act_413393941247349', + label: 'Detuatuformacion (pendiente permiso API system user)', + }); + await patchIntegration(detu.id, 'facebook', { + status: 'disconnected', + enabled: false, + external_account_id: null, + label: 'Facebook Page (pendiente página propia De Tú a Tú)', + }); + await patchIntegration(detu.id, 'instagram', { + status: 'disconnected', + enabled: false, + external_account_id: null, + label: 'Instagram (pendiente cuenta propia De Tú a Tú)', + }); + + await patchTenant(detu.id, { + agency_followed: true, + name: 'De Tú a Tú Formación', + creatives_enabled: false, + meta_ad_account_id: 'act_413393941247349', + meta_page_id: null, + meta_ig_user_id: null, + default_landing_url: null, + drive_folder_id: '1PWUL2sn18n9bIURwfuc8YIKfhbl1BQfg', + agency_notes: `[${today}] Separado de ECP. Marca De Tú a Tú Formación. Google Ads 3183140753 · Drive 08-Detuatu · Meta act_413393941247349 (otorgar ads_read al system user API 4V2030). RRSS/Meta page propias pendientes; ECP ya no se gestiona aquí.`, + }); + console.log('[detuatu / ecp] separated'); + + // ——— 3. Create Talento Scholar Program ——— + let scholar = bySlug['talento-scholar-program']; + if (!scholar) { + const created = await rest< + { id: string; slug: string; name: string }[] + >('tenants', { + method: 'POST', + body: JSON.stringify({ + slug: 'talento-scholar-program', + name: 'Talento Scholar Program', + agency_followed: true, + creatives_enabled: false, + agency_notes: `[${today}] Separado de Talento y Deporte. Línea académica / Scholar (becas de estudio USA). Emparejar Meta Ads, IG y Drive propios cuando existan.`, + default_landing_url: 'https://talentoydeporte.com', + }), + }); + scholar = created[0]; + console.log('[talento-scholar-program] created', scholar?.id); + } else { + await patchTenant(scholar.id, { + agency_followed: true, + name: 'Talento Scholar Program', + agency_notes: `[${today}] Cliente separado de Talento y Deporte · línea Scholar / becas académicas.\n${scholar.agency_notes || ''}`.slice( + 0, + 4000 + ), + }); + console.log('[talento-scholar-program] already existed — activated'); + } + + // Seed empty integrations row set is usually done by app on first open; + // ensure notes on Talento y Deporte clarify split + const tyd = bySlug['talento-y-deporte']; + if (tyd) { + await patchTenant(tyd.id, { + agency_followed: true, + name: 'Talento y Deporte', + drive_folder_id: '1IwXdvcVFKpHta4jf3nxT7k7qltzSufuL', + default_landing_url: 'https://talentoydeporte.com', + meta_ad_account_id: 'act_1188310622211934', + meta_page_id: '189855164776887', + meta_ig_user_id: '17841405373441110', + agency_notes: `[${today}] Línea deportiva / becas deportivas USA. Sibling: Talento Scholar Program (académico). Drive 06-Talento y Deporte · IG @talentoydeporte.`, + }); + console.log('[talento-y-deporte] notes + ids synced'); + } + + // ——— 4. Sync drive_folder_id from google_drive for all followed tenants ——— + const driveRows = await rest< + { tenant_id: string; external_account_id: string; label?: string }[] + >( + 'integrations?provider=eq.google_drive&status=eq.connected&select=tenant_id,external_account_id,label' + ); + for (const row of driveRows) { + // Skip bad ECP christmas folder as root — keep label but don't force as client root if name is Navidad + if (/navidad/i.test(row.label || '')) { + console.log( + `[drive] skip suspicious folder for tenant ${row.tenant_id}: ${row.label}` + ); + continue; + } + await patchTenant(row.tenant_id, { + drive_folder_id: row.external_account_id, + }); + } + console.log(`[drive] synced folder ids for ${driveRows.length} tenants`); + + // ——— 5. Enrich brandbooks for active portfolio (skip archived) ——— + const active = await rest<{ id: string; slug: string; name: string }[]>( + 'tenants?agency_followed=neq.false&select=id,slug,name&order=name.asc' + ); + for (const t of active) { + const r = await enrichTenantBrandbook({ tenantId: t.id, forceSeed: true }); + const drive = await ensureCreativesDriveFolders(t.id, t.slug); + console.log( + `[enrich] ${t.slug} ${r.completenessBefore}→${r.completenessAfter} drive=${drive.ok ? 'ok' : drive.warning}` + ); + } + + console.log('Done · active tenants', active.length); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/src/app/api/admin/create-client/route.ts b/src/app/api/admin/create-client/route.ts index cacb75b..4be3606 100644 --- a/src/app/api/admin/create-client/route.ts +++ b/src/app/api/admin/create-client/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; import { createClient } from '@/lib/supabase/server'; +import { isAgencyRole, userRole as resolveUserRole } from '@/lib/agency'; export async function POST(req: NextRequest) { try { @@ -10,8 +11,7 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: 'No autorizado. Inicia sesión.' }, { status: 401 }); } - const userRole = user.app_metadata?.role || user.user_metadata?.role || 'client'; - if (userRole !== 'admin' && userRole !== 'team') { + if (!isAgencyRole(resolveUserRole(user))) { return NextResponse.json({ error: 'Permisos insuficientes. Solo administradores pueden crear clientes.' }, { status: 403 }); } diff --git a/src/app/api/assets/drive/route.ts b/src/app/api/assets/drive/route.ts index da58b88..8895d35 100644 --- a/src/app/api/assets/drive/route.ts +++ b/src/app/api/assets/drive/route.ts @@ -190,7 +190,7 @@ export async function GET(req: NextRequest) { mapped = filterDriveEntriesForClientRoot(mapped); if (!mapped.length && !scopeWarning) { scopeWarning = - 'Aún no hay carpetas generadas por la app (p. ej. Pipeline-creatividades). El resto del Drive solo lo ve la agencia.'; + 'Aún no hay carpetas generadas por la app (Creatividades-Ads / Creatividades-RRSS). El resto del Drive solo lo ve la agencia.'; } } diff --git a/src/app/api/brandbook/route.ts b/src/app/api/brandbook/route.ts index a143b93..55b5dd3 100644 --- a/src/app/api/brandbook/route.ts +++ b/src/app/api/brandbook/route.ts @@ -16,7 +16,11 @@ import { seedBrandbook, type Brandbook, } from '@/lib/brandbook'; -import { diffFields, logChange } from '@/lib/change-history'; +import { + buildRevertPatch, + diffFields, + logChange, +} from '@/lib/change-history'; export const dynamic = 'force-dynamic'; @@ -286,6 +290,14 @@ export async function PUT(req: NextRequest) { book as unknown as Record ); + const revertPatch = + !body.seed && !created + ? buildRevertPatch( + previousBook as unknown as Record, + book as unknown as Record + ) + : {}; + await logChange({ tenantId, tenantName: label, @@ -306,6 +318,8 @@ export async function PUT(req: NextRequest) { : `Guardado Zona de marca · ${label}`, detail: { changes: changes.slice(0, 40), + revert_patch: revertPatch, + revertable: Object.keys(revertPatch).length > 0, seed: Boolean(body.seed), slug, }, diff --git a/src/app/api/calendar/linkedin-strategy/route.ts b/src/app/api/calendar/linkedin-strategy/route.ts new file mode 100644 index 0000000..d82c468 --- /dev/null +++ b/src/app/api/calendar/linkedin-strategy/route.ts @@ -0,0 +1,116 @@ +/** + * GET — resolve LinkedIn strategy URL + summary for active tenant + * (brandbook URL, known slug map, optional ?url= override). + * Used by CalendarCreator / zoom to show associated strategy content. + */ +import { NextRequest, NextResponse } from 'next/server'; +import { createClient } from '@/lib/supabase/server'; +import { resolveActiveTenant, restGet } from '@/lib/tenant-context'; +import { isAgencyRole, userRole } from '@/lib/agency'; +import { + BRANDBOOK_DOC_TYPE, + parseBrandbook, +} from '@/lib/brandbook'; +import { + loadLinkedInStrategyContext, + resolveLinkedInStrategyUrl, +} from '@/lib/linkedin-strategy'; + +export const dynamic = 'force-dynamic'; +export const maxDuration = 45; + +export async function GET(req: NextRequest) { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + if (!isAgencyRole(userRole(user))) { + return NextResponse.json({ error: 'Solo agencia' }, { status: 403 }); + } + const ctx = await resolveActiveTenant(user); + if (!ctx.tenantId || ctx.portfolioWide) { + return NextResponse.json( + { error: 'Selecciona un cliente en Contexto.' }, + { status: 400 } + ); + } + + const override = req.nextUrl.searchParams.get('url') || ''; + const postId = req.nextUrl.searchParams.get('post_id') || ''; + + let slug = 'cliente'; + const t = await restGet<{ slug?: string }[]>( + `/rest/v1/tenants?id=eq.${encodeURIComponent(ctx.tenantId)}&select=slug` + ); + slug = t.data?.[0]?.slug || ctx.label.toLowerCase().replace(/\s+/g, '-'); + + const bb = await restGet<{ content?: unknown }[]>( + `/rest/v1/documents?tenant_id=eq.${encodeURIComponent( + ctx.tenantId + )}&type=eq.${BRANDBOOK_DOC_TYPE}&select=content&order=updated_at.desc&limit=1` + ); + const brandbook = parseBrandbook(bb.data?.[0]?.content); + + const url = resolveLinkedInStrategyUrl({ + brandbook, + slug, + overrideUrl: override, + }); + + if (!url) { + return NextResponse.json({ + ok: true, + url: null, + brandbook_url: brandbook?.linkedin_strategy_url || null, + posts: [], + themes: brandbook?.linkedin_themes || [], + formats: brandbook?.linkedin_formats || [], + ctas: brandbook?.linkedin_cta || [], + message: 'Sin URL de estrategia LinkedIn en brandbook ni mapa conocido.', + }); + } + + const ctxLi = await loadLinkedInStrategyContext({ + url, + brandbook, + mode: 'from_strategy', + }); + + const posts = (ctxLi?.posts || []).map((p) => ({ + id: p.id, + n: p.n, + title: p.title, + meta: p.meta, + angle: p.angle, + body_preview: (p.body || '').slice(0, 500), + body: postId && p.id === postId ? p.body : undefined, + })); + + let selected = postId + ? ctxLi?.posts.find((p) => p.id === postId) || null + : null; + + return NextResponse.json({ + ok: true, + url, + brandbook_url: brandbook?.linkedin_strategy_url || null, + posts_count: ctxLi?.posts.length || 0, + posts: posts.slice(0, 80), + selected: selected + ? { + id: selected.id, + title: selected.title, + meta: selected.meta, + angle: selected.angle, + body: selected.body, + } + : null, + themes: brandbook?.linkedin_themes || [], + formats: brandbook?.linkedin_formats || [], + ctas: brandbook?.linkedin_cta || [], + thought_leadership: brandbook?.thought_leadership || [], + }); +} diff --git a/src/app/api/calendar/preview/route.ts b/src/app/api/calendar/preview/route.ts index 678bd46..93a65af 100644 --- a/src/app/api/calendar/preview/route.ts +++ b/src/app/api/calendar/preview/route.ts @@ -18,6 +18,13 @@ import { } from '@/lib/pipeline-calendar'; import type { PublishedNetworkPost } from '@/lib/calendar-strategy'; import { normalizeApprovalStatus } from '@/lib/approval-status'; +import { normalizeGenOptions } from '@/lib/calendar-gen-options'; +import { + collectLinkedInUsage, + loadLinkedInStrategyContext, + pickUnpublishedStrategyPosts, + resolveLinkedInStrategyUrl, +} from '@/lib/linkedin-strategy'; export const dynamic = 'force-dynamic'; export const maxDuration = 60; @@ -57,9 +64,10 @@ export async function POST(req: NextRequest) { .map((t) => t.trim().slice(0, 160)) .slice(0, 40) : []; - const genOptions = (body.gen_options || body.options || null) as + const genOptionsRaw = (body.gen_options || body.options || null) as | Record | null; + const genOptions = normalizeGenOptions(genOptionsRaw || undefined); let slug = 'cliente'; const t = await restGet<{ slug?: string }[]>( @@ -129,6 +137,58 @@ export async function POST(req: NextRequest) { }); } + let linkedinStrategyUrl = ''; + let linkedinPrompt: string | undefined; + let linkedinStrategyPosts: + | import('@/lib/linkedin-strategy').LinkedInStrategyPost[] + | undefined; + + if (genOptions.linkedinPosts > 0 || genOptions.linkedinStrategyUrl) { + linkedinStrategyUrl = resolveLinkedInStrategyUrl({ + brandbook, + slug, + overrideUrl: genOptions.linkedinStrategyUrl, + }); + if (linkedinStrategyUrl) { + const liCtx = await loadLinkedInStrategyContext({ + url: linkedinStrategyUrl, + brandbook, + mode: genOptions.linkedinMode, + }); + if (liCtx) { + linkedinStrategyPosts = liCtx.posts; + const usage = collectLinkedInUsage({ + history, + publishedPosts, + avoidTitles, + }); + const pick = pickUnpublishedStrategyPosts( + liCtx.posts, + usage, + genOptions.linkedinPosts || liCtx.posts.length + ); + linkedinPrompt = [ + liCtx.promptBlock, + `ESTADO BACKLOG ESTRATEGIA: ${pick.publishedOrPlanned.length} ya publicados o planificados · ${pick.unpublished.length} pendientes.`, + pick.publishedOrPlanned.length + ? `YA USADOS (no re-agendar): ${pick.publishedOrPlanned + .slice(0, 40) + .map((p) => p.id) + .join(', ')}` + : '', + pick.unpublished.length + ? `PENDIENTES (priorizar en orden): ${pick.unpublished + .slice(0, 24) + .map((p) => `${p.id} — ${p.title}`) + .join(' | ')}` + : 'Backlog agotado → crear ideas nuevas en línea de marca.', + ] + .filter(Boolean) + .join('\n'); + } + } + } + const result = await generateMonthPreview({ ym: month, slug, @@ -137,7 +197,10 @@ export async function POST(req: NextRequest) { brandbook, publishedPosts, avoidTitles, - genOptions: genOptions || undefined, + genOptions, + linkedinStrategyUrl: linkedinStrategyUrl || undefined, + linkedinPrompt, + linkedinStrategyPosts, }); return NextResponse.json({ @@ -147,6 +210,9 @@ export async function POST(req: NextRequest) { source: result.source, brand: slug, brandbook: !!brandbook, + linkedin_strategy_url: linkedinStrategyUrl || null, + linkedin_posts_from_strategy: linkedinStrategyPosts?.length || 0, + linkedin_schedule: result.linkedinMeta || null, history_months: history.length, network_posts: publishedPosts.length, }); diff --git a/src/app/api/change-history/undo/route.ts b/src/app/api/change-history/undo/route.ts new file mode 100644 index 0000000..ff9ee66 --- /dev/null +++ b/src/app/api/change-history/undo/route.ts @@ -0,0 +1,262 @@ +/** + * POST /api/change-history/undo + * Body: { id: string } + * Deshace un cambio reversible y registra el deshacer en el historial. + */ +import { NextRequest, NextResponse } from 'next/server'; +import { createClient } from '@/lib/supabase/server'; +import { + homeTenantId, + isAgencyRole, + supabaseUrl, + userRole, +} from '@/lib/agency'; +import { + isHistoryRowRevertable, + logChange, + type ChangeHistoryRow, +} from '@/lib/change-history'; +import { + BRANDBOOK_DOC_TITLE, + BRANDBOOK_DOC_TYPE, + parseBrandbook, + type Brandbook, +} from '@/lib/brandbook'; +import { createClient as createAdminClient } from '@supabase/supabase-js'; +import { resolveActiveTenant } from '@/lib/tenant-context'; + +export const dynamic = 'force-dynamic'; + +function getAdmin() { + const url = supabaseUrl(); + const key = process.env.SUPABASE_SERVICE_ROLE_KEY?.trim(); + if (!url || !key) return null; + return createAdminClient(url, key, { + auth: { persistSession: false, autoRefreshToken: false }, + }); +} + +function patchFromChanges( + changes: { field?: string; from?: unknown }[] +): Record { + const patch: Record = {}; + for (const c of changes) { + if (!c?.field) continue; + const from = c.from; + if (typeof from === 'string' && from.endsWith('…')) { + // Valor clipado — no fiable para deshacer + continue; + } + patch[c.field] = from ?? null; + } + return patch; +} + +export async function POST(req: NextRequest) { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const role = userRole(user); + const isAgency = isAgencyRole(role); + const body = (await req.json().catch(() => ({}))) as { id?: string }; + const id = String(body.id || '').trim(); + if (!id) { + return NextResponse.json({ error: 'id requerido' }, { status: 400 }); + } + + const admin = getAdmin(); + if (!admin) { + return NextResponse.json({ error: 'Sin service role' }, { status: 503 }); + } + + const { data: row, error: fetchErr } = await admin + .from('change_history') + .select('*') + .eq('id', id) + .maybeSingle(); + + if (fetchErr || !row) { + return NextResponse.json( + { error: fetchErr?.message || 'Cambio no encontrado' }, + { status: 404 } + ); + } + + const historyRow = row as ChangeHistoryRow; + + // Autorización por tenant + if (!isAgency) { + const own = homeTenantId(user); + const ctx = await resolveActiveTenant(user); + const allowed = own || ctx.tenantId; + if (!allowed || historyRow.tenant_id !== allowed) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + } + + if (!isHistoryRowRevertable(historyRow)) { + return NextResponse.json( + { + error: + 'Este cambio no se puede deshacer (ya deshecho, seed/creación, o sin parche reversible).', + }, + { status: 400 } + ); + } + + const detail = historyRow.detail || {}; + let revertPatch: Record = {}; + if ( + detail.revert_patch && + typeof detail.revert_patch === 'object' && + !Array.isArray(detail.revert_patch) + ) { + revertPatch = { ...(detail.revert_patch as Record) }; + } else if (Array.isArray(detail.changes)) { + revertPatch = patchFromChanges( + detail.changes as { field?: string; from?: unknown }[] + ); + } + + if (!Object.keys(revertPatch).length) { + return NextResponse.json( + { + error: + 'No hay datos suficientes para deshacer (valores anteriores incompletos).', + }, + { status: 400 } + ); + } + + if (historyRow.entity_type !== 'brandbook') { + return NextResponse.json( + { + error: + 'De momento solo se pueden deshacer cambios de Zona de marca (brandbook).', + }, + { status: 400 } + ); + } + + const tenantId = historyRow.tenant_id; + const docId = historyRow.entity_id; + if (!tenantId || !docId) { + return NextResponse.json( + { error: 'Falta tenant o documento en el registro' }, + { status: 400 } + ); + } + + const { data: doc, error: docErr } = await admin + .from('documents') + .select('id, content, type') + .eq('id', docId) + .eq('tenant_id', tenantId) + .maybeSingle(); + + if (docErr || !doc) { + return NextResponse.json( + { error: docErr?.message || 'Documento brandbook no encontrado' }, + { status: 404 } + ); + } + + const current = parseBrandbook(doc.content); + if (!current) { + return NextResponse.json( + { error: 'Brandbook ilegible' }, + { status: 500 } + ); + } + + const beforeUndo = { ...(current as unknown as Record) }; + const restoredFields: string[] = []; + const book = { ...current } as Brandbook & Record; + + for (const [field, value] of Object.entries(revertPatch)) { + if (field.startsWith('_')) continue; + if (value === null) { + // Campo que no existía: vaciar tipos comunes + const cur = book[field]; + if (Array.isArray(cur)) book[field] = []; + else if (typeof cur === 'string') book[field] = ''; + else if (typeof cur === 'object' && cur) book[field] = value; + else book[field] = value; + } else { + book[field] = value as never; + } + restoredFields.push(field); + } + + book.updated_at = new Date().toISOString(); + + const { error: patchErr } = await admin + .from('documents') + .update({ + content: book, + title: BRANDBOOK_DOC_TITLE, + updated_at: book.updated_at, + type: BRANDBOOK_DOC_TYPE, + }) + .eq('id', docId); + + if (patchErr) { + return NextResponse.json({ error: patchErr.message }, { status: 500 }); + } + + // Marca el original como deshecho (sigue visible en historial) + const markedDetail = { + ...detail, + undone: true, + undone_at: new Date().toISOString(), + undone_by: user.email || user.id, + }; + await admin + .from('change_history') + .update({ detail: markedDetail }) + .eq('id', id); + + const label = + historyRow.tenant_name || + (await resolveActiveTenant(user)).label || + 'cliente'; + + await logChange({ + tenantId, + tenantName: label, + actor: { + userId: user.id, + email: user.email, + role, + }, + entityType: 'brandbook', + entityId: docId, + action: 'undo', + summary: `Deshacer · ${historyRow.summary}`.slice(0, 500), + detail: { + undoes: id, + undoes_summary: historyRow.summary, + undoes_at: historyRow.created_at, + restored_fields: restoredFields, + changes: restoredFields.map((f) => ({ + field: f, + from: beforeUndo[f] ?? null, + to: revertPatch[f] ?? null, + })), + revertable: false, + note: 'El cambio original permanece en el historial marcado como deshecho.', + }, + source: 'api/change-history/undo', + }); + + return NextResponse.json({ + ok: true, + undid: id, + restored_fields: restoredFields, + }); +} diff --git a/src/app/api/creative-pipeline/generate/route.ts b/src/app/api/creative-pipeline/generate/route.ts index 5239aeb..f6ec110 100644 --- a/src/app/api/creative-pipeline/generate/route.ts +++ b/src/app/api/creative-pipeline/generate/route.ts @@ -74,10 +74,16 @@ export async function POST(req: NextRequest) { const { data: tenant } = await db .from('tenants') - .select('id, slug, name') + .select('id, slug, name, canva_templates') .eq('id', tenantId) .maybeSingle(); + const { resolveTenantCanvaTemplates } = await import('@/lib/canva-templates'); + const canvaTemplates = resolveTenantCanvaTemplates(tenant?.canva_templates, { + slug: tenant?.slug, + channel: 'rrss', + }); + // Recent pipeline for continuity const { data: recentRows } = await db .from('creative_pipeline') @@ -216,6 +222,7 @@ export async function POST(req: NextRequest) { brandbookPrompt, socialHistory, variantsRequested: Number(body.variants_requested || 2), + canvaTemplates, }); const { data: gr, error: grErr } = await db diff --git a/src/app/api/creative-pipeline/route.ts b/src/app/api/creative-pipeline/route.ts index 05d84da..6a0ffd8 100644 --- a/src/app/api/creative-pipeline/route.ts +++ b/src/app/api/creative-pipeline/route.ts @@ -8,6 +8,7 @@ import { } from '@/lib/agency'; import { resolveActiveTenant } from '@/lib/tenant-context'; import { + channelsForCalendarPlatform, normalizeChannels, parseScheduledAt, PUBLISH_STATUSES, @@ -227,13 +228,7 @@ export async function POST(req: NextRequest) { scheduledAt = scheduledAtFromPiece(cal); } if (!channels) { - const plat = (cal.platform || '').toLowerCase(); - channels = - plat.includes('linkedin') - ? ['instagram'] - : plat.includes('facebook') - ? ['facebook', 'instagram'] - : ['instagram', 'facebook']; + channels = channelsForCalendarPlatform(cal.platform); } } } diff --git a/src/app/api/creative-pipeline/upload/route.ts b/src/app/api/creative-pipeline/upload/route.ts index e7ee146..611a8c4 100644 --- a/src/app/api/creative-pipeline/upload/route.ts +++ b/src/app/api/creative-pipeline/upload/route.ts @@ -22,7 +22,7 @@ const ALLOWED_MIME = new Set(['image/jpeg', 'image/png', 'image/webp']); * fields: file, itemId (requerido), slot ('cover' | 'slide'), index? (para slide, 0-based) * * También espeja el archivo en Drive del cliente: - * {carpeta-cliente}/Pipeline-creatividades_{slug}/YYYY/MM/DD-MM-YYYY_formato_hook[-N].ext + * {carpeta-cliente}/Creatividades-RRSS_{slug}/YYYY/MM/{slug}_rrss_{formato}_{fecha}_vN.ext */ export async function POST(req: NextRequest) { const supabase = await createClient(); diff --git a/src/app/api/creatives/upload/route.ts b/src/app/api/creatives/upload/route.ts index dc24925..b52016d 100644 --- a/src/app/api/creatives/upload/route.ts +++ b/src/app/api/creatives/upload/route.ts @@ -15,9 +15,11 @@ import { serviceSupabase, } from '@/lib/assets'; import { randomUUID } from 'crypto'; +import { syncCreativeAssetToDrive } from '@/lib/creatives-drive'; export const dynamic = 'force-dynamic'; export const runtime = 'nodejs'; +export const maxDuration = 90; const ALLOWED_MIME = new Set([ 'image/jpeg', @@ -170,6 +172,8 @@ export async function POST(req: NextRequest) { patch.asset_final = true; } + let creative: Record | null = null; + if (agency && headers) { const r = await fetch( `${supabaseUrl()}/rest/v1/creatives?id=eq.${encodeURIComponent(creativeId)}`, @@ -188,21 +192,53 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: await r.text() }, { status: 500 }); } const data = await r.json(); - return NextResponse.json({ - creative: Array.isArray(data) ? data[0] : data, - media_url: mediaUrl, - }); + creative = Array.isArray(data) ? data[0] : data; + } else { + const { data, error } = await supabase + .from('creatives') + .update(patch) + .eq('id', creativeId) + .select('*') + .single(); + if (error) { + await svc.storage.from(ASSETS_BUCKET).remove([storagePath]); + return NextResponse.json({ error: error.message }, { status: 500 }); + } + creative = data; } - const { data, error } = await supabase - .from('creatives') - .update(patch) - .eq('id', creativeId) - .select('*') - .single(); - if (error) { - await svc.storage.from(ASSETS_BUCKET).remove([storagePath]); - return NextResponse.json({ error: error.message }, { status: 500 }); - } - return NextResponse.json({ creative: data, media_url: mediaUrl }); + const ext = + safeName.split('.').pop()?.toLowerCase() || + (mime.includes('png') ? 'png' : mime.includes('webp') ? 'webp' : 'jpg'); + + const drive = await syncCreativeAssetToDrive({ + tenantId: ctx.tenantId, + clientLabel: ctx.label || 'cliente', + channel: 'ads', + item: { + id: creativeId, + title: String(creative?.title || title), + format: String(creative?.format || (mime.startsWith('video/') ? 'video' : 'image')), + created_at: String(creative?.created_at || new Date().toISOString()), + angle: creative?.angle ? String(creative.angle) : null, + hook: creative?.hook ? String(creative.hook) : null, + }, + buffer: buf, + mimeType: mime, + ext, + slot: 'asset', + }); + + return NextResponse.json({ + creative, + media_url: mediaUrl, + drive: { + ok: drive.ok, + skipped: drive.skipped || false, + path: drive.drivePath || null, + fileName: drive.fileName || null, + fileId: drive.driveFileId || null, + warning: drive.warning || null, + }, + }); } diff --git a/src/app/api/integrations/creative-pipeline/route.ts b/src/app/api/integrations/creative-pipeline/route.ts index e7f141f..800a95b 100644 --- a/src/app/api/integrations/creative-pipeline/route.ts +++ b/src/app/api/integrations/creative-pipeline/route.ts @@ -6,6 +6,7 @@ import { parseGenerationRequestIdFromNotes, stripGenerationRequestMarker, } from '@/lib/rrss-generation'; +import { backfillCreativeItemToDrive } from '@/lib/creatives-drive'; export const dynamic = 'force-dynamic'; @@ -110,7 +111,7 @@ export async function POST(req: NextRequest) { const { data: item } = await db .from('creative_pipeline') - .select('id, notes, caption, format') + .select('id, notes, caption, format, title, scheduled_at, created_at') .eq('id', pipelineId) .eq('tenant_id', tenantId) .maybeSingle(); @@ -236,5 +237,42 @@ export async function POST(req: NextRequest) { .eq('tenant_id', tenantId); } - return NextResponse.json({ ok: true, item: updated }); + // Mirror final assets into Creatividades-RRSS_{slug}/YYYY/MM/… + let drive: { ok: boolean; detail?: string } | null = null; + if (coverPath && !/^https?:\/\//i.test(coverPath)) { + try { + const { data: tenant } = await db + .from('tenants') + .select('slug,name') + .eq('id', tenantId) + .maybeSingle(); + const bf = await backfillCreativeItemToDrive({ + tenantId, + clientLabel: tenant?.slug || tenant?.name || 'cliente', + channel: 'rrss', + item: { + id: pipelineId, + title: updated.title || item.title, + format: updated.format || item.format, + caption: updated.caption, + notes: updated.notes, + scheduled_at: updated.scheduled_at || item.scheduled_at, + created_at: updated.created_at || item.created_at, + cover_path: updated.cover_path, + slide_paths: updated.slide_paths, + }, + }); + drive = { + ok: bf.ok, + detail: bf.results.map((x) => `${x.slot}:${x.detail}`).join(' | '), + }; + } catch (e) { + drive = { + ok: false, + detail: e instanceof Error ? e.message : 'drive sync failed', + }; + } + } + + return NextResponse.json({ ok: true, item: updated, drive }); } diff --git a/src/app/api/integrations/creatives/[id]/route.ts b/src/app/api/integrations/creatives/[id]/route.ts index 1754224..470e076 100644 --- a/src/app/api/integrations/creatives/[id]/route.ts +++ b/src/app/api/integrations/creatives/[id]/route.ts @@ -38,18 +38,11 @@ export async function GET( .eq('id', tenantId) .maybeSingle(); - const defaultBpsTemplate = process.env.BPS_CANVA_TEMPLATE_ID || 'EAHRb38v_WY'; - let canvaTemplates = (client as any)?.canva_templates || {}; - if ( - client?.slug === 'bps' && - (!canvaTemplates || Object.keys(canvaTemplates).length === 0) - ) { - canvaTemplates = { - 'foto-con-franja': defaultBpsTemplate, - tipografico: null, - default: defaultBpsTemplate, - }; - } + const { resolveTenantCanvaTemplates } = await import('@/lib/canva-templates'); + const canvaTemplates = resolveTenantCanvaTemplates( + (client as { canva_templates?: unknown })?.canva_templates, + { slug: client?.slug, channel: 'ads' } + ); return NextResponse.json({ data: { @@ -65,7 +58,7 @@ export async function GET( meta_publish_status: client?.meta_publish_status || 'PAUSED', slack_channel_id: client?.slack_channel_id, canva_templates: canvaTemplates, - drive_folder_id: (client as any)?.drive_folder_id || null, + drive_folder_id: (client as { drive_folder_id?: string | null })?.drive_folder_id || null, }, }, }); @@ -186,9 +179,83 @@ export async function PATCH( }); } + // Mirror final Ads asset → Creatividades-Ads_{slug}/YYYY/MM/{slug}_ads_… + let drive: { ok: boolean; path?: string; warning?: string } | null = null; + if (updates.asset_final || updates.asset_url || updates.asset_final === true) { + try { + const { syncCreativeAssetToDrive } = await import('@/lib/creatives-drive'); + const { ASSETS_BUCKET, serviceSupabase } = await import('@/lib/assets'); + const { data: tenant } = await adminClient + .from('tenants') + .select('slug,name') + .eq('id', tenantId) + .maybeSingle(); + + let buffer: Buffer | null = null; + let mimeType = 'image/jpeg'; + let ext = 'jpg'; + const storagePath = updated.storage_path || creative.storage_path; + const assetUrl = updated.asset_url || creative.asset_url; + + if (storagePath && typeof storagePath === 'string' && !/^https?:\/\//i.test(storagePath)) { + const svc = serviceSupabase(); + if (svc) { + const dl = await svc.storage.from(ASSETS_BUCKET).download(storagePath); + if (dl.data) { + buffer = Buffer.from(await dl.data.arrayBuffer()); + mimeType = dl.data.type || mimeType; + ext = storagePath.split('.').pop()?.toLowerCase() || ext; + } + } + } else if (assetUrl && typeof assetUrl === 'string' && /^https?:\/\//i.test(assetUrl)) { + const r = await fetch(assetUrl, { cache: 'no-store' }); + if (r.ok) { + buffer = Buffer.from(await r.arrayBuffer()); + mimeType = r.headers.get('content-type') || mimeType; + ext = mimeType.includes('png') + ? 'png' + : mimeType.includes('webp') + ? 'webp' + : 'jpg'; + } + } + + if (buffer) { + const sync = await syncCreativeAssetToDrive({ + tenantId, + clientLabel: tenant?.slug || tenant?.name || 'cliente', + channel: 'ads', + item: { + id, + title: updated.title || creative.title, + format: updated.format || creative.format || 'image', + created_at: updated.created_at || creative.created_at, + angle: updated.angle || creative.angle, + hook: updated.hook || creative.hook, + }, + buffer, + mimeType, + ext, + slot: 'asset', + }); + drive = { + ok: sync.ok, + path: sync.drivePath, + warning: sync.warning, + }; + } + } catch (e) { + drive = { + ok: false, + warning: e instanceof Error ? e.message : 'drive sync failed', + }; + } + } + return NextResponse.json({ ok: true, data: updated, + drive, ...updated, }); } diff --git a/src/app/api/integrations/creatives/route.ts b/src/app/api/integrations/creatives/route.ts index aed93d4..7fce63c 100644 --- a/src/app/api/integrations/creatives/route.ts +++ b/src/app/api/integrations/creatives/route.ts @@ -114,18 +114,11 @@ export async function POST(req: NextRequest) { .eq('id', tenantId) .maybeSingle(); - const defaultBpsTemplate = process.env.BPS_CANVA_TEMPLATE_ID || 'EAHRb38v_WY'; - let canvaTemplates = tenant?.canva_templates || {}; - if ( - tenant?.slug === 'bps' && - (!canvaTemplates || Object.keys(canvaTemplates).length === 0) - ) { - canvaTemplates = { - 'foto-con-franja': defaultBpsTemplate, - tipografico: null, - default: defaultBpsTemplate, - }; - } + const { resolveTenantCanvaTemplates } = await import('@/lib/canva-templates'); + const canvaTemplates = resolveTenantCanvaTemplates(tenant?.canva_templates, { + slug: tenant?.slug, + channel: 'ads', + }); const clientInfo = { id: tenant?.id || tenantId, @@ -227,7 +220,7 @@ export async function GET(req: NextRequest) { return NextResponse.json({ error: error.message }, { status: 500 }); } - const defaultBpsTemplate = process.env.BPS_CANVA_TEMPLATE_ID || 'EAHRb38v_WY'; + const { resolveTenantCanvaTemplates } = await import('@/lib/canva-templates'); const enriched = (rows || []) .filter((row: any) => { @@ -236,17 +229,10 @@ export async function GET(req: NextRequest) { }) .map((row: any) => { const { tenant, ...rest } = row; - let canvaTemplates = tenant?.canva_templates || {}; - if ( - tenant?.slug === 'bps' && - (!canvaTemplates || Object.keys(canvaTemplates).length === 0) - ) { - canvaTemplates = { - 'foto-con-franja': defaultBpsTemplate, - tipografico: null, - default: defaultBpsTemplate, - }; - } + const canvaTemplates = resolveTenantCanvaTemplates(tenant?.canva_templates, { + slug: tenant?.slug, + channel: 'ads', + }); const item: Record = { ...rest, diff --git a/src/app/auth/actions.ts b/src/app/auth/actions.ts index 3153b7b..de2c6e4 100644 --- a/src/app/auth/actions.ts +++ b/src/app/auth/actions.ts @@ -1,10 +1,27 @@ 'use server'; import { createClient } from '@/lib/supabase/server'; +import { cookies } from 'next/headers'; import { redirect } from 'next/navigation'; +import { + VIEW_TENANT_COOKIE, + VIEW_TENANT_NAME_COOKIE, + VIEW_TENANT_SLUG_COOKIE, + clearedViewTenantCookieOpts, +} from '@/lib/agency'; export async function logoutAction() { const supabase = await createClient(); await supabase.auth.signOut(); + + // Evita que el siguiente login herede "vista cliente" de otra sesión. + const jar = await cookies(); + const opts = clearedViewTenantCookieOpts( + process.env.NODE_ENV === 'production' + ); + jar.set(VIEW_TENANT_COOKIE, '', opts); + jar.set(VIEW_TENANT_SLUG_COOKIE, '', opts); + jar.set(VIEW_TENANT_NAME_COOKIE, '', opts); + redirect('/login'); } diff --git a/src/app/dashboard/agencia/page.tsx b/src/app/dashboard/agencia/page.tsx index 9693bbd..9fd0240 100644 --- a/src/app/dashboard/agencia/page.tsx +++ b/src/app/dashboard/agencia/page.tsx @@ -5,6 +5,7 @@ import AgencyPortfolioBoard from '@/components/AgencyPortfolioBoard'; import { createClient } from '@/lib/supabase/server'; import { redirect } from 'next/navigation'; import { PageHeader } from '@/components/ui'; +import { isAgencyRole, userRole } from '@/lib/agency'; export const revalidate = 0; @@ -15,8 +16,7 @@ export default async function AgencyPage() { } = await supabase.auth.getUser(); if (!user) redirect('/login'); - const role = user.app_metadata?.role || user.user_metadata?.role || 'client'; - if (role !== 'admin' && role !== 'team') { + if (!isAgencyRole(userRole(user))) { redirect('/dashboard/kpis'); } diff --git a/src/app/dashboard/layout.tsx b/src/app/dashboard/layout.tsx index 5976592..d228934 100644 --- a/src/app/dashboard/layout.tsx +++ b/src/app/dashboard/layout.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { createClient } from '@/lib/supabase/server'; import { logoutAction } from '@/app/auth/actions'; import { cookies } from 'next/headers'; +import { redirect } from 'next/navigation'; import { VIEW_TENANT_COOKIE, VIEW_TENANT_NAME_COOKIE, @@ -30,16 +31,26 @@ export default async function DashboardLayout({ data: { user }, } = await supabase.auth.getUser(); + // Sin usuario real no falsificamos identidad (antes salía usuario@4ventures.es). + if (!user?.email) { + redirect('/login'); + } + const role = userRole(user); const company = - (user?.user_metadata?.company as string) || - (user?.app_metadata?.company as string) || - '4Ventures Central'; - const email = user?.email || 'usuario@4ventures.es'; + (user.user_metadata?.company as string) || + (user.app_metadata?.company as string) || + (isAgencyRole(role) ? '4Ventures' : 'Portal cliente'); + const email = user.email; const isAgency = isAgencyRole(role); const cookieStore = await cookies(); - const viewName = cookieStore.get(VIEW_TENANT_NAME_COOKIE)?.value; - const viewId = cookieStore.get(VIEW_TENANT_COOKIE)?.value || null; + // Cookies de zoom solo aplican a agencia (evita chrome cliente en ops). + const viewName = isAgency + ? cookieStore.get(VIEW_TENANT_NAME_COOKIE)?.value + : undefined; + const viewId = isAgency + ? cookieStore.get(VIEW_TENANT_COOKIE)?.value || null + : null; const ownId = homeTenantId(user); let enabledModules: Record | null = null; @@ -94,9 +105,7 @@ export default async function DashboardLayout({ isAgency={isAgency} viewName={viewName} enabledModules={enabledModules} - clientLogoUrl={ - isAgency && viewId ? clientLogoUrl : !isAgency ? clientLogoUrl : null - } + clientLogoUrl={!isAgency ? clientLogoUrl : null} brandLabel={brandLabel} logoutAction={logoutAction} > diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 1610259..4bcfb09 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -68,7 +68,12 @@ function LoginForm() { )} -
+
setEmail(e.target.value)} - placeholder="ejemplo@cliente.com" + placeholder="marketing@4ventures.es" className="w-full pl-10 pr-4 py-2.5 bg-gray-50 border border-gray-200 rounded-xl text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:border-brand-500 focus:ring-1 focus:ring-brand-500 transition-all" /> @@ -98,6 +105,8 @@ function LoginForm() { setPassword(e.target.value)} diff --git a/src/components/AssetsLibrary.tsx b/src/components/AssetsLibrary.tsx index ad263a9..9a50cd2 100644 --- a/src/components/AssetsLibrary.tsx +++ b/src/components/AssetsLibrary.tsx @@ -922,7 +922,7 @@ export default function AssetsLibrary({

{isAgency ? `Misma carpeta enlazada en Agencia (Clientes → ${driveFolder || '…'}).` - : 'Solo carpetas creadas por la app (p. ej. Pipeline-creatividades). El resto del Drive del cliente no se muestra.'} + : 'Solo carpetas creadas por la app (Creatividades-Ads / Creatividades-RRSS, calendarios, brandbook…). El resto del Drive del cliente no se muestra.'}

{loading ? (

Cargando Drive…

diff --git a/src/components/CalendarCreator.tsx b/src/components/CalendarCreator.tsx index ac4e333..4d0f397 100644 --- a/src/components/CalendarCreator.tsx +++ b/src/components/CalendarCreator.tsx @@ -1,9 +1,12 @@ 'use client'; -import React, { useMemo, useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { useRouter } from 'next/navigation'; import { CalendarPlus, + ExternalLink, + Instagram, + Linkedin, Loader2, Plus, Sparkles, @@ -138,20 +141,67 @@ export default function CalendarCreator({ ...DEFAULT_GEN_OPTIONS, }); const [showGenPanel, setShowGenPanel] = useState(true); + /** Pestaña del creador: IG/FB vs LinkedIn */ + const [creatorTab, setCreatorTab] = useState<'ig-fb' | 'linkedin'>('ig-fb'); + const [liStrategyMeta, setLiStrategyMeta] = useState<{ + url: string | null; + posts_count: number; + themes: string[]; + } | null>(null); + + useEffect(() => { + if (!open) return; + let cancelled = false; + (async () => { + try { + const r = await fetch('/api/calendar/linkedin-strategy'); + const j = await r.json(); + if (!r.ok || cancelled) return; + setLiStrategyMeta({ + url: j.url || j.brandbook_url || null, + posts_count: j.posts_count || 0, + themes: j.themes || [], + }); + if (j.url || j.brandbook_url) { + setGenOptions((o) => + o.linkedinStrategyUrl + ? o + : { + ...o, + linkedinStrategyUrl: String(j.url || j.brandbook_url || ''), + } + ); + } + } catch { + /* ignore */ + } + })(); + return () => { + cancelled = true; + }; + }, [open]); const monthChoices = useMemo(() => { const base = ymNow(); return Array.from({ length: 6 }, (_, i) => addMonths(base, i)); }, []); + const filteredPieces = useMemo(() => { + const all = plan?.pieces || []; + if (creatorTab === 'linkedin') { + return all.filter((p) => /linkedin/i.test(p.platform || '')); + } + return all.filter((p) => !/linkedin/i.test(p.platform || '')); + }, [plan, creatorTab]); + const byDate = useMemo(() => { const m = new Map(); - for (const p of plan?.pieces || []) { + for (const p of filteredPieces) { const k = p.date.slice(0, 10); (m.get(k) || m.set(k, []).get(k)!).push(p); } return m; - }, [plan]); + }, [filteredPieces]); const grid = useMemo(() => { if (!month) return { pad: 0, days: 0 }; @@ -213,7 +263,21 @@ export default function CalendarCreator({ setBusy(true); setErr(null); try { - const opts = normalizeGenOptions(genOptions); + let opts = normalizeGenOptions(genOptions); + // Pestaña LinkedIn: prioriza LI (default 8) y no regenera IG/FB en este preview + if (creatorTab === 'linkedin') { + if (opts.linkedinPosts <= 0) { + opts = { ...opts, linkedinPosts: 8 }; + setGenOptions((o) => ({ ...o, linkedinPosts: 8 })); + } + opts = { + ...opts, + posts: 0, + stories: 0, + reels: 0, + carruseles: 0, + }; + } const keepIds = mode === 'regen_selected' && plan?.pieces?.length ? new Set( @@ -264,6 +328,26 @@ Regeneradas: ${regenIds.size} · Conservadas: ${ setSource(`${j.source || 'heuristic'}+regen`); setRegenIds(new Set()); } + } else if (creatorTab === 'linkedin' && plan?.pieces?.length) { + // Conserva IG/FB existentes; sustituye solo LinkedIn + const keepIg = (plan.pieces || []).filter( + (p) => !/linkedin/i.test(p.platform || '') + ); + const newLi = (j.plan?.pieces || []).filter((p: RrssPiece) => + /linkedin/i.test(p.platform || '') + ); + const merged: RrssMonthPlan = { + ...(j.plan || plan), + pieces: [...keepIg, ...newLi].sort((a, b) => + a.date.localeCompare(b.date) + ), + }; + setPlan(merged); + setRationale( + `${j.rationale || ''}\n\nLinkedIn regenerado (${newLi.length}); IG/FB conservados (${keepIg.length}).` + ); + setSource(`${j.source || 'heuristic'}+li`); + setRegenIds(new Set()); } else { setPlan(j.plan); setRationale(j.rationale || ''); @@ -440,7 +524,11 @@ Regeneradas: ${regenIds.size} · Conservadas: ${ } } - const dayPieces = dayZoom ? byDate.get(dayZoom) || [] : []; + const dayPieces = useMemo(() => { + if (!dayZoom) return [] as RrssPiece[]; + // En el drawer del día mostramos todas las redes; el tab solo filtra el grid + return (plan?.pieces || []).filter((p) => p.date.slice(0, 10) === dayZoom); + }, [dayZoom, plan]); return ( <> @@ -523,6 +611,52 @@ Regeneradas: ${regenIds.size} · Conservadas: ${ ) : ( <>
+
+ + +
+ {creatorTab === 'ig-fb' ? (
{( [ @@ -628,6 +763,124 @@ Regeneradas: ${regenIds.size} · Conservadas: ${ ))}
+ ) : ( +
+

+ Generación LinkedIn B2B +

+ +
+

+ Fuente del copy +

+
+ {( + [ + ['from_strategy', 'Estrategia (copie listo)'], + ['ideas', 'Ideas nuevas'], + ['mixed', 'Mixto'], + ] as const + ).map(([mode, label]) => ( + + ))} +
+
+ + {liStrategyMeta?.url ? ( +

+ Detectada:{' '} + + {liStrategyMeta.url.replace(/^https?:\/\//, '').slice(0, 56)} + + + {liStrategyMeta.posts_count + ? ` · ${liStrategyMeta.posts_count} posts en estrategia` + : ''} +

+ ) : ( +

+ Sin estrategia en brandbook: pega la URL o guárdala en Zona de marca → LinkedIn B2B. +

+ )} + {liStrategyMeta?.themes?.length ? ( +

+ Temas: {liStrategyMeta.themes.slice(0, 5).join(' · ')} +

+ ) : null} +