Compare commits

..

90 Commits

Author SHA1 Message Date
4Ventures
e3180ceb51 feat: sync Clientify CRM + botón Sincronizar por pestaña
El CRM solo vivía de webhooks; ahora hay pull Clientify, cron daily-sync incluye CRM, y cada módulo de Agencia tiene sync manual.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-30 15:34:40 +02:00
4Ventures
c11ec20f26 fix: Meta sync usa token agency_oauth (no solo env BPS)
El META_ACCESS_TOKEN de Dokploy solo tenía acceso a BPS; el de agency_oauth cubre el resto. Probamos candidatos y priorizamos agency.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-29 17:33:17 +02:00
4Ventures
1a58af129d chore: scripts Dokploy/BPS (cron secret, Ads MCC, sync y probes)
Incluye utilidades de ops para CRON_SECRET, login customer Google Ads, sync de seguimiento BPS y probes de conexiones.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-29 16:48:44 +02:00
4Ventures
8c5acb626f feat: columna ad_daily.link_clicks (Meta clics enlace reales)
Persistimos link_clicks en warehouse (con backfill desde __lc), sync escribe la columna y creatividades la leen sin romper si falta.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-29 10:50:55 +02:00
4Ventures
82a910330d perf: acortar lookback KPIs y unificar fallback Google warehouse
Quita el suelo de 700 días (basta desde ene-2025) y deja buildGoogleCardsFromDaily solo en ad-creatives-google.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-29 09:36:41 +02:00
4Ventures
a75f3ae30c perf: creatividades warehouse-first + aviso OAuth Ads Production
Carga métricas desde ad_daily al instante (mode=fast) y enriquece previews en vivo; la UI de Agencia avisa si falta scope Ads o consent Testing.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-29 09:35:45 +02:00
4Ventures
939c4a3131 fix: Meta creatividades nunca fallan — retries, ventanas cortas y fallback silencioso
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-29 02:06:28 +02:00
4Ventures
9270584934 fix: Meta Ads muestra clics todos y clics enlace (y ambos CPC)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-29 01:51:45 +02:00
4Ventures
ed2afff556 fix: CPL creatividades con céntimos
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-29 01:33:39 +02:00
4Ventures
c83efa492d fix: Ads CPC con céntimos + clics enlace Meta; gráficas barras/línea con ejes
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-29 01:28:59 +02:00
4Ventures
5e8fd66e35 fix: Meta creatividades — 5 titulares, 5 textos y descripción
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-28 18:50:56 +02:00
4Ventures
331c19e873 fix: creatividades — Google siempre con fallback; Meta preview 9:16 y textos reales
OAuth Ads caducado: aviso amable + campañas warehouse. Meta deja de volcar todas las variantes del asset_feed.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-28 18:46:24 +02:00
4Ventures
6d8dfaa79c fix: Ads Meta conv/CPL = Clientes potenciales (sin sumar acciones solapadas)
Las creatividades inflaban ~3× al sumar lead + lead_grouped + registration.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-28 18:32:51 +02:00
4Ventures
990915a051 fix: Superhuman sync solo agencia — mensaje claro y botón oculto a cliente
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-28 18:05:17 +02:00
4Ventures
c0707ffc1b fix: Social — solo posts IG y barras de evolución visibles con delta
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-28 17:57:56 +02:00
4Ventures
87c887294d fix: Social FB posts sin likes/comments.summary (evita error #10 Meta)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-28 15:15:07 +02:00
4Ventures
aa41594e1e fix: Social — métricas IG total_value, posts en formato original y tamaño
Rellena views/perfil/clics/interacciones con metric_type=total_value y series día a día; carruseles/vídeos/reels con aspect ratio y px originales.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-28 14:31:12 +02:00
4Ventures
530b5ac5ae fix: Social IG/FB con métricas Meta vigentes y creatividades Ad Tracker
Quita copy "Paid ads en Ads", migra Page Insights a page_media_view/page_follows y resuelve creatividades Meta a nivel anuncio.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-28 14:19:58 +02:00
4Ventures
4a9c2139f7 fix: KPIs leen ad_daily desde ene 2025 para YoY completo
El lookback fijo de 420 días cortaba el histórico; ahora cubre desde 2025-01-01.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-28 13:02:40 +02:00
4Ventures
83e2b59e83 fix: MoM mes-en-curso al mes previo real y pipelines CRM históricos
El filtro Online se aplica por rango de fechas; backfill de pipelines para que agosto/YoY no queden en 4 ventas. MoM de «este mes» usa los mismos días del mes anterior.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-28 12:46:08 +02:00
4Ventures
5235c88c13 fix: escapar '>' en copy de KPIs para no romper el build JSX
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-28 12:20:33 +02:00
4Ventures
062c43325d fix: ventas KPI = Online Clientify (89) y atribución por fuente real
Excluye Málaga del cohort; enriquece source desde lead/Clientify para que el cruce ads vs CRM deje de volcar todo en sin-atribuir.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-28 12:19:57 +02:00
4Ventures
48fb31d247 fix: alinear KPIs BPS al milímetro con Ads Manager y Clientify Online
Meta solo cuenta registration leads; Google guarda fracciones y redondea al sumar; ventas CRM usan pipeline Online con importe > 0.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-28 12:10:09 +02:00
4Ventures
4c8042aa23 fix: KPIs — ocultar WoW >7d, YoY con histórico completo, ventas en nº
Pagina ad_daily (~420d) para que YoY no quede vacío; Ventas muestra
conteo de deals (importe Clientify en hint/columna Importe).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-28 11:05:21 +02:00
4Ventures
dff9c46747 fix: excluir scripts del typecheck de Next para desbloquear el build
El deploy fallaba tipando scripts/sync_* (dotenv). También tipa el
fallback de select leads en KPIs/CRM.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-28 10:41:52 +02:00
4Ventures
cf27eba74d feat: KPIs con totales absolutos, backfill ads y ventas en cruce canal
Comparativas WoW/MoM/YoY muestran prev/Δ además del %; sync ads admite
histórico desde 2025; tabla ads↔CRM añade cierres, ventas y ROAS.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-28 10:23:36 +02:00
4Ventures
dd50251de2 feat: editor enriquecido en notas de Seguimiento
Barra tipo Docs (negrita, color, resaltado, listas, enlaces, tamaño) al crear notas.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-24 17:35:42 +02:00
4Ventures
9f30aa6820 fix: narrowing de posts en Social API
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-24 17:31:36 +02:00
4Ventures
b0d19e4da5 fix: tipado Social al mezclar posts FB/IG
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-24 17:31:02 +02:00
4Ventures
5e5fc87710 feat: Social con gráficas diarias por periodo y token Meta 4V2030
Series IG/FB (alcance, impresiones, clics, engagement, fans) según el rango del header; Meta y Google quedan como tokens separados.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-24 17:30:44 +02:00
4Ventures
224f060e9d fix: Social FB — Page token + aviso si el Meta token no tiene pages_*
El token de ads no sirve para /posts; intentamos Page Access Token y explicamos los permisos necesarios.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-24 17:26:22 +02:00
4Ventures
04ea6de08a fix: notas Seguimiento Semanal con HTML completo, enlaces y formato
Exporta el Google Doc en HTML, parte por fecha y guarda todo el bloque (listas, negritas, links) sin truncar.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-24 17:20:31 +02:00
4Ventures
751673c341 fix: vídeos en Social y notas Superhuman por día desde Drive
Los docs de seguimiento son Google Docs embebidos: se exportan, se parten por fecha y se acumulan. Social renderiza VIDEO/REELS y trae vídeos de página.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-24 17:09:49 +02:00
4Ventures
06024cf473 feat: auditoría SEO real con histórico y avisos
Crawl técnico (HTTPS/robots/sitemap/on-page) + tabla seo_audits; UI con nota, checks y alertas.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-24 16:55:58 +02:00
4Ventures
d896041bed fix: menú SEO realista — paneles distintos y sin stubs Semrush vacíos
Recorta la suite a secciones con GSC/GA4 (overview, auditoría checklist, rankings con histórico, tendencia, páginas, keywords, insights, contenido). Competencia/backlinks quedan como “herramienta externa”. Ya no se reutiliza el mismo dashboard en ítems distintos.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-24 16:47:24 +02:00
4Ventures
4b6f7f1896 feat: Ads KPIs completos, Social feed+analytics, Orgánico por sección
Meta/Google con las mismas métricas que Resumen y solo campañas/ads con actividad en el periodo; Social con insights FB/IG y preview de posts (likes/comentarios/shares); Orgánico con paneles distintos por ítem del menú y prioridades/notas solo en visión general.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-24 16:30:17 +02:00
4Ventures
f4dfc50cc9 feat: calendario bajo Social, SEO/GEO con datos live, Drive = 03-Clientes
Calendario editorial anidado en Social; Orgánico con queries/páginas GSC y landings GA4 + dashboard GEO; Biblioteca enlazada a la carpeta real del cliente bajo 03-Clientes (no carpetas homónimas); previews Ads solo en Meta/Google.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-24 16:12:07 +02:00
4Ventures
3425364b07 feat: periodo global, métricas negocio, seguimiento notas, Drive/Social/Orgánico
Selector de fechas en header (con Personalizado) compartido en el dashboard; Métricas con Ventas/CAC/ROAS CRM, cruce canal y gráficas diarias; Seguimiento solo notas + sync Superhuman; Activos con navegación Drive; Social con métricas IG/FB; Orgánico con prioridades SEO/GEO expertas.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-24 15:45:30 +02:00
4Ventures
eb1622c88c fix: import PageHeader desde ui en SocialDashboard
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-24 13:45:58 +02:00
4Ventures
ba05626fed feat: menú Ads + Social; Drive por defecto en activos
Renombra Ad Tracker a Ads, añade módulo Social (perfiles RRSS)
y abre Biblioteca en pestaña Drive. Aclara histórico GSC en Orgánico.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-24 13:44:56 +02:00
4Ventures
7ed6e641cb feat: al conectar Google, auto-emparejar Drive/GA4/GSC/Ads en fichas
Bootstrap unificado post-OAuth + botón Emparejar todo. Meta discover
también intenta FB/IG pages con el token system user (API 4V2030).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-24 12:41:51 +02:00
4Ventures
99895fe918 feat: conectar GA4/GSC/Drive desde la ficha (verificar + sync real)
Al guardar ID o URL en el cliente se valida con OAuth de agencia;
Drive acepta URL o folder id; Orgánico se rellena si hay acceso.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-24 12:27:15 +02:00
4Ventures
81ba5df37d fix: ficha no finge sync al guardar IDs; normaliza URL de Drive
«Conectar» en la ficha solo guarda la referencia; last_sync queda
para syncs reales. Drive acepta URL completa → folder id.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-24 12:22:41 +02:00
4Ventures
5501f45a67 fix: guardar OAuth Google con upsert real (PATCH vacío no insertaba)
El callback creía haber guardado el token y fallaba al emparejar Drive
con «no conectado». Upsert on_conflict=provider + match no bloquea OAuth.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-24 12:17:46 +02:00
4Ventures
5d04a79de7 fix: una sola conexión Google en Agencia (quitar botón duplicado)
El panel orgánico ya no ofrece Conectar: solo emparejar/actualizar
sobre el OAuth del panel 1.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-24 12:13:28 +02:00
4Ventures
d137b5796a feat: Orgánico siempre al día (auto-sync al abrir + cron diario)
El botón Sync 28d pasa a «Actualizar ahora»; la vista refresca sola
si faltan datos y hay endpoint /api/cron/organic-sync.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-24 12:10:02 +02:00
4Ventures
aded4c3514 feat: OAuth Google unificado (Drive+GA4+GSC) y sync a Orgánico
Conectar marketing@ con scopes Analytics/GSC; descubrir propiedades,
emparejar fichas y sincronizar organic_daily 28d.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-24 12:04:05 +02:00
4Ventures
81ede58330 feat: suite Orgánico tipo Semrush (SEO + GEO por secciones)
Navegación por visión general, competencia, prompts, percepción,
auditoría, keywords, backlinks y prioridades accionables por cliente.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-24 11:25:32 +02:00
4Ventures
5c90bcf940 feat: vista Orgánico con SEO (GA4/GSC) y GEO (visibilidad IA)
Nueva sección con métricas Search Console/GA4, cimientos SEO y
Share of Model por motor (ChatGPT/Gemini/AI Overviews).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-24 11:18:26 +02:00
4Ventures
dd0e867108 feat: ficha por cliente en portafolio (conexiones, accesos, logo, módulos)
Cada tenant tiene una ficha con plataformas, invitaciones por email,
logo de marca y toggles de módulos visibles en el portal cliente.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-24 10:51:21 +02:00
4Ventures
21051afda9 fix: cohort CAC/ROAS desde deals Clientify y más contraste en dashboards
Los totales de Métricas usaban solo ad_daily (cohort a 0). Ahora suman
deals won; selectores y tipografía ganan contraste sobre fondo blanco.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-24 10:40:12 +02:00
4Ventures
8fda2996b6 feat: rango de fechas personalizado y datos hasta ayer (salvo Hoy)
Selector libre en Ad Tracker/Seguimiento; presets y KPIs cierran en ayer.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-24 10:24:25 +02:00
4Ventures
068faa3d25 fix: tema claro blanco, logo 4Ventures y menú original
Fondo blanco en la app; menú con labels/iconos originales; logo en sidebar y login.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-24 09:53:36 +02:00
4Ventures
6d1602bd68 chore: quitar import unused en calendario
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-24 09:44:09 +02:00
4Ventures
bf9a504c40 feat: interfaz clara unificada en Centro de Mando
Shell con nav agrupada y activa, PageHeader común, tipografía y tokens.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-24 09:32:35 +02:00
4Ventures
8c11291396 fix: paginar asset_group_asset de Google para no perder vídeos/imágenes
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-23 18:40:39 +02:00
4Ventures
8091498508 feat: zoom de creatividades con assets verticales Meta y PMax completo
Preview 4:5 + textos en card; modal con todas las imágenes, vídeos y copy.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-23 18:38:02 +02:00
4Ventures
d05df8c371 fix: previews Meta vía proxy y creatividades Google PMax/RSA
Las imágenes Meta dejan de romperse en el navegador; Google trae assets en vivo.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-23 18:25:48 +02:00
4Ventures
86225a701e feat: previews de creatividades en Ad Tracker (estilo Brava)
Meta Graph en vivo + ficha Google; galería por anuncio en resumen/Meta/Google.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-23 18:16:14 +02:00
4Ventures
be45df300b feat: pestaña CRM con % a cliente por canal y cruce ads vs Clientify
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-23 17:33:41 +02:00
4Ventures
f436276c3e feat: endpoint webhook Clientify para sync en tiempo real
URL pública por tenant (?tenant=slug) para contactos y oportunidades;
middleware permite POST sin sesión.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-23 15:40:41 +02:00
4Ventures
76c26c2e69 fix: KPIs abre en 7d como Ad Tracker por defecto
Misma ventana inicial (hoy-6…hoy) para que los totales coincidan al abrir ambas pantallas.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-23 13:01:46 +02:00
4Ventures
fb313fc38f fix: alinear KPIs con Ad Tracker por días calendario
KPIs filtraba por inicio de semana (omitía días de Sep en la semana
del 31-ago); ahora Mes/7d/30d suman ad_daily día a día como Ad Tracker.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-23 12:38:45 +02:00
4Ventures
fba025da76 fix: mostrar Google Ads desde ad_daily cuando kpi_weekly está vacío
kpi_weekly es una MV de solo lectura sin canales paid; canal/seguimiento
ahora hacen fallback a ad_daily. Bump Google Ads API a v21 en sync.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-23 12:13:41 +02:00
4Ventures
a1a00840d5 feat: sync Meta metrics, objetivos ppto/consecución y simulador por tenant
Rellena ad_daily desde Meta, KPIs con fallback a daily, objetivos de presupuesto vs consecución, y simulador sin TikTok anclado a métricas del cliente.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-23 10:59:15 +02:00
4Ventures
0d286dc66c feat: upload creativos, PMax formats, portada IG/FB y seguimiento con notas
Permite subir media sin URL; previews Google PMax por ratio; calendario anual estilo Miia con filtros IG/FB y LinkedIn; seguimiento con selector de fechas, KPIs y cuadro de notas.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-23 09:33:22 +02:00
4Ventures
a7704567bd feat: preview de placement en pipeline de creatividades
Cada pieza muestra mock Meta/Google Ads o RRSS (feed, Stories, LinkedIn) como el Ad Tracker, con copy, media y canal editables.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-23 03:36:28 +02:00
4Ventures
b4ee23874e feat: RRSS hub estilo Miia + Ad Tracker por cliente
Replica agua-miia/rrss (hub, archivo, octubre 2026 con 27 piezas) y añade Ad Tracker por tenant al estilo Brava (periodos, Meta+Google, CPL/clics, campañas).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-23 02:55:14 +02:00
4Ventures
a1a3c26c30 feat: dual agency/client approval on calendar and assets
Private RRSS calendar with unified + per-network tabs, comments, and
double-check OK. Assets get review drawer with preview/comments. KPIs
gain date presets, North Star strip, channel matrix and per-channel dash.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-23 02:38:44 +02:00
4Ventures
5460d5bd04 fix: exclude P&L and HR pages from Superhuman client sync
Keeps agency-only ops out of the client-facing seguimiento timeline.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 23:01:30 +02:00
4Ventures
982a9e2954 chore: Dokploy helper to merge Superhuman Docs API key
Keeps existing portal env vars and triggers deploy after merge.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 21:41:58 +02:00
4Ventures
64156383ca feat: interactive seguimiento timeline and channel evolution
Sync Superhuman Docs seguimiento/KPI pages into the portal (never passwords)
and show clients a dynamic timeline plus per-channel kpi_weekly charts.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 21:34:21 +02:00
4Ventures
6607a02bff feat: paste Meta token after App Review; Google-only discover
While Meta review is pending, agency can match Google Ads alone and
paste the system user token later without a redeploy.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 21:11:03 +02:00
4Ventures
c13001c69d feat: auto-discover Meta and Google Ads accounts for tenants
Agency panel lists accessible ad accounts and matches them to
clients by name, same pattern as Drive folders.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 19:24:11 +02:00
4Ventures
eb00256bea feat: add Instagram, Facebook, TikTok and LinkedIn slots
Organic social channels per client in the agency hub, separate from
paid Meta Ads.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 18:58:29 +02:00
4Ventures
670679d09c feat: add WordPress, PrestaShop and Shopify connection slots
Per-client CMS/ecommerce channels in the agency hub with URL/API
placeholders; live sync can come later.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 18:50:25 +02:00
4Ventures
c4c527072f feat: agency control panel for per-client connections
Matrix view of Meta/Google/Drive/Clientify, follow/archive clients,
and per-channel ON/OFF with account IDs in the client hub.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 18:47:29 +02:00
4Ventures
1eb02dac4d feat: Google Drive agency sync with auto folder match
Connect marketing@ once, match Drive folders to tenants by name,
list Drive in Activos, and mirror portal uploads to the linked folder.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 16:27:54 +02:00
4Ventures
31ca77558e fix: keep Activos as file library only
Agent documents (calendar/copy/audit) belong in Calendario/ops, not the asset upload surface.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 15:41:52 +02:00
4Ventures
ce9498e63c feat: upload any validated file to the asset library
Activos now supports multi-file/folder upload to private Storage
(PDF, Excel, CSV, images, ZIP, etc.) with per-tenant folders.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 15:26:33 +02:00
4Ventures
8182402d96 feat: metrics dashboard with WoW/MoM/YoY and creatives pipeline
Rebuild KPIs as a real dashboard (period deltas, tenant zoom), clarify
objetivos/calendario/activos sources, and turn creatives into a status
pipeline with delete.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 13:45:41 +02:00
4Ventures
791976a3d8 feat: agency portfolio with per-client integrations hub
Admin home lists tenants, alerts, and connection health; header
selector enables superadmin context; Meta/GA4/GSC/GTM/CRM/email
slots live per tenant (manual status until OAuth).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 01:31:47 +02:00
4Ventures
9d1609c88b docs: note JWT rotation ready but blocked on supabase compose
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-21 18:47:08 +02:00
4Ventures
5d448c0e71 ops: apply Traefik office allowlist for git and n8n UIs
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-21 18:20:25 +02:00
4Ventures
73b3693eeb docs: record agent_runs RLS fix and Traefik IP candidate
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-21 18:16:44 +02:00
4Ventures
8315a8098f fix: stop baking Supabase anon key into the Docker image
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-21 18:07:04 +02:00
4Ventures
aa89df86a1 chore: ignore dokploy shots and add chat/traefik ops smokes
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-21 18:02:55 +02:00
4Ventures
3c741fa457 docs: refresh operator runbook and creatives copy after prod cutover
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-21 17:46:43 +02:00
4Ventures
997ccf66d7 fix: unblock production Docker build (chat query + middleware types)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-21 13:31:16 +02:00
ec642b057a Merge pull request 'Harden auth + tenant security + creatives scaffold' (#1) from harden/auth-tenant-security into main 2026-09-21 10:22:54 +00:00
196 changed files with 38994 additions and 1180 deletions

View File

@@ -8,3 +8,25 @@ ANTHROPIC_API_KEY=replace-with-anthropic-key
OPS_ALERT_EMAIL=
CRON_SECRET=
N8N_WEBHOOK_BASE=https://n8n-agencia.4ventures.es
# Google Drive (agency marketing@) — OAuth Web client
NEXT_PUBLIC_APP_URL=https://app.4ventures.es
GOOGLE_OAUTH_CLIENT_ID=
GOOGLE_OAUTH_CLIENT_SECRET=
# Optional override; default: {APP_URL}/api/admin/drive/callback
# GOOGLE_OAUTH_REDIRECT_URI=https://app.4ventures.es/api/admin/drive/callback
# Meta Ads discovery (long-lived / system user token)
META_ACCESS_TOKEN=
# Google Ads discovery (MCC via marketing@)
GOOGLE_ADS_DEVELOPER_TOKEN=
GOOGLE_ADS_REFRESH_TOKEN=
# Optional: reuse GOOGLE_OAUTH_CLIENT_* or set dedicated:
# GOOGLE_ADS_CLIENT_ID=
# GOOGLE_ADS_CLIENT_SECRET=
# GOOGLE_ADS_LOGIN_CUSTOMER_ID=
# Superhuman Docs (optional)
# SUPERHUMAN_DOCS_API_KEY=

34
.gitignore vendored
View File

@@ -13,6 +13,40 @@ dist/
.env
.env.*
!.env.example
.ops/
# local ops noise
scripts/dokploy-shots/
# one-shot cutover / debug (kept locally; not for main)
scripts/dokploy_cleanup_and_sql.py
scripts/dokploy_configure_deploy_portal.py
scripts/dokploy_create_and_deploy_portal.py
scripts/dokploy_cutover_portal.py
scripts/dokploy_deploy_portal_now.py
scripts/dokploy_env_reveal_save.py
scripts/dokploy_finalize_portal.py
scripts/dokploy_find_app_host.py
scripts/dokploy_find_supabase.py
scripts/dokploy_inspect_env.py
scripts/dokploy_inspect_portal.py
scripts/dokploy_inventory_domains.py
scripts/dokploy_redeploy_portal.py
scripts/dokploy_retarget_traefik.py
scripts/dokploy_run_creatives_sql.py
scripts/dokploy_set_env_and_ops.py
scripts/dokploy_sql_terminal_b64.py
scripts/dokploy_sql_via_terminal.py
scripts/dokploy_stop_orphan.py
scripts/dokploy_traefik_cm.py
scripts/dokploy_traefik_expand.py
scripts/dokploy_traefik_insert.py
scripts/dokploy_traefik_only.py
scripts/dokploy_traefik_unlock.py
scripts/dokploy_view_deploy_error.py
scripts/dokploy_collect_supabase_env.py
scripts/dokploy_find_jwt_secret.py
scripts/dokploy_prepare_key_rotation.py
tsconfig.tsbuildinfo
# debug / OS
npm-debug.log*

View File

@@ -12,8 +12,8 @@ COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN mkdir -p public
ENV NEXT_TELEMETRY_DISABLED=1
ENV NEXT_PUBLIC_SUPABASE_URL=https://supabase.4ventures.es
ENV NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE3NzIzMTgzMTAsImV4cCI6MTg5MzQ1NjAwMCwicm9sZSI6ImFub24iLCJpc3MiOiJzdXBhYmFzZSJ9.UWNn1leZNljna9Qwi9U5RtGqsh5_SnEoiPO8HkQIgi8
# NEXT_PUBLIC_* must come from Dokploy "Create Environment File" (.env) or build-args.
# Do not bake secrets into the image.
RUN npm run build
FROM base AS runner

View File

@@ -30,6 +30,7 @@ Health: `GET /api/health`
|------|-----|
| `/login` | Supabase Auth |
| `/dashboard/kpis` | KPIs |
| `/dashboard/seguimiento` | Timeline Superhuman + evolución canales |
| `/dashboard/estrategia` | Targets + simulador |
| `/dashboard/activos` | Docs aprobados (RLS) |
| `/dashboard/creatives` | Creatividades (en curso) |

View File

@@ -5,11 +5,21 @@
El PDF “Agencia 2030” y el antiguo `.env.example` del repo contenían **JWTs reales**
(anon + service_role) y passwords de demo.
1. En Supabase Studio → Settings → API: **regenerar** `anon` y `service_role`.
2. Actualizar secrets en Dokploy / env del contenedor `app` y de n8n.
3. Cambiar passwords de `admin@…` y clientes demo; invalidar sesiones.
4. Rotar `ANTHROPIC_API_KEY` si estuvo en chats/docs.
5. Confirmar que `.env.example` solo tiene placeholders (ya corregido en este repo).
### Rotación (checklist — hacerlo en ventana corta)
1. Supabase Studio → Settings → API → **regenerar** `anon` y `service_role`
**o** (self-hosted) cambiar `JWT_SECRET` / `GOTRUE_JWT_SECRET` y emitir JWTs nuevos.
2. Dokploy → client-portal Environment: pegar anon + service_role nuevos → Save → Deploy.
3. n8n (credenciales Supabase / HTTP headers): actualizar API keys.
4. Local: actualizar `.env.local` (gitignored); no tocar `.env.example` (placeholders).
5. Invalidar sesiones: Auth → Users → sign-out / cambiar password `marketing@4ventures.es` si hubo leak de demo.
6. Smoke: `curl /api/health` → `serviceRoleConfigured true`; `python3 scripts/smoke_operator_login.py`.
7. Rotar `ANTHROPIC_API_KEY` si estuvo en chats/docs (opcional, independiente).
**Estado 2026-09-21:** claves nuevas **preparadas** en `.ops/supabase_rotated.env` (gitignored).
**Aplicación bloqueada:** el stack vivo (`ventures-web-supabase-gm0nnn-*`) no tiene compose editable en Dokploy (`4ventures-infra` está en `error` / `composeFile` vacío) y SSH al VPS (`187.77.170.176:22`) hace timeout. Sin recrear contenedores con el nuevo `JWT_SECRET`, los JWT filtrados **siguen válidos**.
**Desbloqueo:** acceso host (SSH/VPN) o restaurar el compose Supabase en Dokploy → sustituir env → recrear servicios auth/kong/rest/studio → Deploy client-portal.
## Superficies a restringir (amenaza)
@@ -20,6 +30,9 @@ El PDF “Agencia 2030” y el antiguo `.env.example` del repo contenían **JWTs
| `supabase` Studio | Solo red interna / SSO admin |
| `app` | Público OK; APIs tras sesión (middleware) |
Patch Traefik **aplicado** 2026-09-21 (ver `.ops/HARDEN_SURFACES.md`).
Allowlist oficina: `90.161.248.78/32` — UI git/n8n; webhooks abiertos. Si el ISP cambia la IP, actualizar middleware `office-allowlist`.
## Controles en código (este repo)
- Middleware exige sesión en `/dashboard/*` y `/api/*` (excepto `/api/health`).

View File

@@ -1,55 +1,79 @@
# Operador marketing@4ventures.es
La contraseña del portal legado **no** vale en Supabase Auth (`invalid_credentials`).
## Estado (2026-09-21)
## Alta / reset en Studio
- Usuario **creado** en Supabase Auth (`role=admin`, `tenant_id` = 4Ventures Central).
- Password local (gitignored): `.ops/marketing-credentials.txt`
- Login verificado en https://app.4ventures.es/login → `/dashboard/*` y `/dashboard/agencia`
- `sql/creatives.sql` **aplicado** en prod; RLS activo en tablas de negocio (incl. `agent_runs`, verificado 2026-09-21)
- Re-aplicar RLS `agent_runs` si hace falta: `sql/agent_runs_rls.sql` (idempotente)
- Biblioteca de activos (archivos): `sql/asset_files.sql` + bucket privado `tenant-assets` (máx. 80 MB/archivo). UI: `/dashboard/activos`. Apply: `python3 scripts/dokploy_apply_asset_files.py`
- Google Drive (agencia): `sql/google_drive.sql`. OAuth marketing@ → scopes Drive + Analytics readonly + Search Console. Redirect: `https://app.4ventures.es/api/admin/drive/callback`. UI: Agencia → Conectar Google. Tras consent: **auto-bootstrap** (Drive + GA4 + GSC + Google Ads MCC → fichas + sync Orgánico). Repetir: panel 2 → Emparejar todo Google.
- Meta (API 4V2030 / system user): pegar token en Agencia → Descubrir ambos. Empareja Meta Ads + páginas Facebook/Instagram a fichas (si el token tiene `pages_show_list` / business).
- Cron diario (recomendado): env `CRON_SECRET` en Dokploy + schedule:
`curl -sS -H "Authorization: Bearer $CRON_SECRET" https://app.4ventures.es/api/cron/organic-sync`
(actualiza últimos 7 días de todos los clientes con GA4/GSC conectados).
- Panel control clientes: `sql/agency_control.sql` (`agency_followed` + `integrations.enabled`). Matriz en Agencia + hub por cliente.
- Meta/Google Ads discovery: env `META_ACCESS_TOKEN`, `GOOGLE_ADS_DEVELOPER_TOKEN`, `GOOGLE_ADS_REFRESH_TOKEN`, `GOOGLE_ADS_CLIENT_ID/SECRET`. UI: Agencia → Descubrir y emparejar.
- Meta App Review: mientras esperas aprobación, usa **Solo Google Ads**. Cuando tengas el system user token, pégalo en Agencia (se guarda en `agency_oauth`, sin redeploy) → Descubrir ambos.
1. Abre https://supabase.4ventures.es (Studio) con cuenta admin de infra.
2. **Authentication → Users → Add user**
- Email: `marketing@4ventures.es`
- Password: genera una fuerte (guárdala en el gestor de la agencia)
- Auto Confirm: sí
3. En el usuario → **User Metadata** / **App Metadata** (JSON), por ejemplo:
## Login
1. Abre https://app.4ventures.es/login
2. Email: `marketing@4ventures.es`
3. Password: la de `.ops/marketing-credentials.txt` (o el gestor de la agencia)
## Reset / reprovision (si hace falta)
Studio (Kong) o Admin API con service role:
```json
{
"role": "admin",
"company": "4Ventures",
"slug": "4ventures-hub"
"slug": "4ventures-hub",
"tenant_id": "0b3c5fc0-6901-4caf-b26a-b6a0201d8541"
}
```
App metadata (preferible para rol):
```json
{
"role": "admin",
"tenant_id": "<uuid-del-tenant-hub-si-existe>"
}
```
4. Si usáis `provision_client`, también podéis:
```sql
select public.provision_client(
'4Ventures',
'4ventures-hub',
'marketing@4ventures.es',
'CONTRASEÑA_FUERTE_AQUI',
'admin'
);
```
5. Login en https://app.4ventures.es/login con ese email/password.
## Rotación de API keys (obligatorio)
## Rotación de API keys (recomendado)
Settings → API → Regenerate `anon` y `service_role`.
Actualizar en Dokploy env del contenedor `app` (y n8n si las usa).
Nunca volver a pegar keys en PDFs ni `.env.example`.
Actualizar en Dokploy → Web4Ventures → `client-portal` → Environment (API `application.update`).
Nunca pegar keys en PDFs ni `.env.example`.
## Pendiente ops
- `ANTHROPIC_API_KEY` en Dokploy env si se quiere el asistente LLM
- OAuth live Meta; Google Ads via env; **GA4/GSC** vía OAuth marketing@ (reconectar Google en Agencia + sync orgánico)
- IP allowlist Traefik: mantener IPs oficina actuales en `middleware-hardening.yml`
- **`CLIENTIFY_WEBHOOK_SECRET` obligatorio** — sin él el webhook responde 503
- **Cron diario (obligatorio en prod)** — ver sección Cron
## Cron diario (datos siempre frescos para vistas cliente)
En Dokploy → Environment del `client-portal`:
1. Define `CRON_SECRET` (string largo aleatorio).
2. Schedule diario ~06:00 UTC:
```bash
curl -sS -H "Authorization: Bearer $CRON_SECRET" \
"https://app.4ventures.es/api/cron/daily-sync?days=7"
```
Endpoints individuales (mismo secret):
- `/api/cron/organic-sync` — GA4 + GSC → `organic_daily`
- `/api/cron/ads-sync` — Meta + Google Ads → `ad_daily` (API v22)
- `/api/cron/superhuman-sync` — Seguimiento / Google Docs
`last_sync_at` solo se actualiza tras escribir filas reales (no al emparejar).
## Verificación
- Rol `admin` → ve `/dashboard/agencia`
- Rol `client` → redirigido fuera de agencia
- Chat/simulate solo con sesión
- Health: `serviceRoleConfigured: true`
- Tras cron: Orgánico / Ads / Seguimiento con `last_sync_at` de hoy en ficha

View File

@@ -1,24 +1,41 @@
# Runbook — Centro de Mando 4Ventures
Operador: `marketing@4ventures.es`
Operador: `marketing@4ventures.es`
Credenciales locales (gitignored): `.ops/marketing-credentials.txt`
## Health
```bash
curl -sf https://app.4ventures.es/api/health
# expect: serviceRoleConfigured true
./scripts/smoke.sh https://app.4ventures.es
python3 scripts/smoke_operator_login.py
python3 scripts/smoke_chat_auth.py
```
## Deploy (Dokploy)
1. Push a `main` en Gitea `Marketing/4ventures-client-portal`.
2. Dokploy rebuild del servicio `app`.
3. Env: `NEXT_PUBLIC_SUPABASE_*`, `SUPABASE_SERVICE_ROLE_KEY`, `ANTHROPIC_API_KEY`.
4. Smoke: `/login` → KPIs → simulador → chat.
2. Dokploy → **Web4Ventures / production / client-portal** → Deploy
(id interno `web4ventures-clientportal-jntjhs`).
3. Environment (runtime + build via “Create Environment File”):
- `NEXT_PUBLIC_SUPABASE_URL`
- `NEXT_PUBLIC_SUPABASE_ANON_KEY`
- `SUPABASE_SERVICE_ROLE_KEY`
- `ANTHROPIC_API_KEY` (opcional, LLM)
4. Traefik: `portal.yml` → `http://web4ventures-clientportal-jntjhs:3000`
5. Smoke: `/login` → KPIs → creatividades → chat.
## Alta cliente
## Portafolio agencia (admin)
1. Login `marketing@` → redirige a `/dashboard/agencia`.
2. Cabecera **Contexto**: selector de cliente (modo superadmin) o vista global.
3. Tarjetas de portafolio: salud de integraciones + **Entrar** / **Integraciones**.
4. Ficha `/dashboard/agencia/cliente/[id]`: Meta, Google Ads, GA4, GSC, GTM, Clientify, Klaviyo, Mailchimp (estado manual por ahora).
5. SQL: `sql/agency_portfolio.sql` (tablas `integrations` + `agency_alerts`).
OAuth live Meta/MCC/GA4 = siguiente fase; el esqueleto ya escala por tenant.
UI `/dashboard/agencia` (roles admin/team) o RPC `provision_client`.
## Atribución
@@ -28,6 +45,17 @@ UI `/dashboard/agencia` (roles admin/team) o RPC `provision_client`.
`POST https://n8n-agencia.4ventures.es/webhook/lead-inbound`
## Endurecer git / n8n
Aplicado 2026-09-21: IP allowlist `90.161.248.78/32` en UI de git + n8n; webhooks `/webhook` abiertos.
Detalle / revert: `.ops/HARDEN_SURFACES.md`. Re-aplicar: `python3 scripts/dokploy_apply_traefik_harden.py`.
`app.4ventures.es` y Dokploy **sin** allowlist.
## Rotación JWT Supabase
Claves nuevas en `.ops/supabase_rotated.env` (gitignored). Aplicar requiere recrear el stack Supabase (ver `SECURITY.md`).
## Incidentes
Ver `SECURITY.md`.

BIN
public/logo-4ventures.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

View File

@@ -0,0 +1,224 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>Preview · Seguimiento · Virginia Molet</title>
<style>
:root { --bg:#0B0F19; --card:#111827; --side:#0D121F; --line:#1F2937; --brand:#E58A3C; --text:#F3F4F6; --muted:#9CA3AF; }
* { box-sizing:border-box; }
body { margin:0; font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, sans-serif; background:var(--bg); color:var(--text); }
.shell { display:flex; min-height:100vh; }
aside { width:240px; background:var(--side); border-right:1px solid #1f2937; padding:20px 14px; }
.logo { display:flex; gap:10px; align-items:center; padding:8px; border-bottom:1px solid #1f293780; margin-bottom:18px; }
.mark { width:36px; height:36px; border-radius:10px; background:linear-gradient(135deg,#E58A3C,#B35E1B); display:grid; place-items:center; font-weight:800; }
nav a { display:flex; gap:10px; align-items:center; color:#D1D5DB; text-decoration:none; padding:10px 12px; border-radius:10px; font-size:14px; margin-bottom:4px; }
nav a.active { background:#1f293799; color:#fff; }
main { flex:1; padding:28px 36px; max-width:980px; }
h1 { margin:0; font-size:28px; letter-spacing:-.02em; }
.sub { color:var(--muted); font-size:14px; margin-top:6px; }
.badge { font-size:11px; color:#9CA3AF; border:1px solid #1f2937; background:#0d121f80; border-radius:8px; padding:8px 12px; }
.card { background:var(--card); border:1px solid #1f2937; border-radius:14px; padding:20px; margin-top:22px; }
.head { display:flex; justify-content:space-between; align-items:center; gap:12px; flex-wrap:wrap; }
.tabs { display:flex; gap:4px; background:#0003; border:1px solid #1f2937; border-radius:10px; padding:3px; }
.tabs span { font-size:12px; padding:6px 10px; border-radius:8px; color:#9CA3AF; }
.tabs span.on { background:#E58A3C33; color:#F0B27A; }
.chs { display:flex; gap:8px; margin:14px 0 8px; flex-wrap:wrap; }
.chs span { font-size:12px; border:1px solid #374151; border-radius:999px; padding:4px 10px; text-transform:capitalize; display:inline-flex; gap:6px; align-items:center; }
.chs i { width:8px; height:8px; border-radius:50%; display:inline-block; }
.preview-note { font-size:11px; color:#FBBF24; margin-top:8px; }
ol { list-style:none; margin:18px 0 0; padding:0 0 0 14px; border-left:1px solid #1f2937; }
li { position:relative; margin:0 0 14px 12px; }
.dot { position:absolute; left:-19px; top:18px; width:12px; height:12px; border-radius:50%; background:var(--brand); box-shadow:0 0 0 4px var(--bg); }
article { background:var(--card); border:1px solid #1f2937; border-radius:14px; padding:14px 16px; }
.row { display:flex; justify-content:space-between; gap:10px; }
.pill { font-size:10px; text-transform:uppercase; letter-spacing:.06em; border:1px solid; border-radius:6px; padding:2px 7px; margin-right:8px; }
.when { font-size:11px; color:#6B7280; }
h3 { margin:8px 0 0; font-size:14px; }
article p { margin:8px 0 0; font-size:12px; color:#9CA3AF; line-height:1.45; }
article a { color:#F0B27A; font-size:11px; text-decoration:none; white-space:nowrap; }
.top { display:flex; justify-content:space-between; align-items:flex-end; gap:16px; flex-wrap:wrap; }
</style>
</head>
<body>
<div class="shell">
<aside>
<div class="logo"><div class="mark">4V</div><div><strong style="font-size:13px">4VENTURES</strong><div style="font-size:11px;color:#9CA3AF">Centro de Mando</div></div></div>
<nav>
<a href="#">Métricas</a>
<a class="active" href="#">● Seguimiento</a>
<a href="#">Objetivos del Mes</a>
<a href="#">Calendario Editorial</a>
<a href="#">Biblioteca de Activos</a>
</nav>
</aside>
<main>
<div class="top">
<div>
<h1>Seguimiento &amp; evolución</h1>
<p class="sub">Virginia Molet · timeline Superhuman + series de canal</p>
</div>
<div class="badge">Preview UI · datos reales de timeline</div>
</div>
<section class="card">
<div class="head">
<strong>Evolución por canal</strong>
<div class="tabs"><span class="on">Leads</span><span>Inversión</span><span>CPL</span></div>
</div>
<div class="chs">
<span><i style="background:#E58A3C"></i>meta</span>
<span><i style="background:#38BDF8"></i>google</span>
<span><i style="background:#34D399"></i>organic</span>
</div>
<svg viewBox="0 0 720 260" width="100%" style="max-width:100%">
<line x1="44" x2="704" y1="224" y2="224" stroke="#1F2937"/><text x="36" y="227" text-anchor="end" fill="#6B7280" font-size="10">0</text><line x1="44" x2="704" y1="172.0" y2="172.0" stroke="#1F2937"/><text x="36" y="175.0" text-anchor="end" fill="#6B7280" font-size="10">18</text><line x1="44" x2="704" y1="120.0" y2="120.0" stroke="#1F2937"/><text x="36" y="123.0" text-anchor="end" fill="#6B7280" font-size="10">36</text><line x1="44" x2="704" y1="68.0" y2="68.0" stroke="#1F2937"/><text x="36" y="71.0" text-anchor="end" fill="#6B7280" font-size="10">54</text><line x1="44" x2="704" y1="16" y2="16" stroke="#1F2937"/><text x="36" y="19" text-anchor="end" fill="#6B7280" font-size="10">72</text>
<path d="M 44.0 102.7 L 138.3 85.3 L 232.6 65.1 L 326.9 76.7 L 421.1 50.7 L 515.4 56.4 L 609.7 30.4 L 704.0 16.0" fill="none" stroke="#E58A3C" stroke-width="2.5"/><circle cx="44.0" cy="102.7" r="3.5" fill="#E58A3C"/><circle cx="138.3" cy="85.3" r="3.5" fill="#E58A3C"/><circle cx="232.6" cy="65.1" r="3.5" fill="#E58A3C"/><circle cx="326.9" cy="76.7" r="3.5" fill="#E58A3C"/><circle cx="421.1" cy="50.7" r="3.5" fill="#E58A3C"/><circle cx="515.4" cy="56.4" r="3.5" fill="#E58A3C"/><circle cx="609.7" cy="30.4" r="3.5" fill="#E58A3C"/><circle cx="704.0" cy="16.0" r="3.5" fill="#E58A3C"/><path d="M 44.0 189.3 L 138.3 183.6 L 232.6 192.2 L 326.9 177.8 L 421.1 172.0 L 515.4 180.7 L 609.7 166.2 L 704.0 160.4" fill="none" stroke="#38BDF8" stroke-width="2.5"/><circle cx="44.0" cy="189.3" r="3.5" fill="#38BDF8"/><circle cx="138.3" cy="183.6" r="3.5" fill="#38BDF8"/><circle cx="232.6" cy="192.2" r="3.5" fill="#38BDF8"/><circle cx="326.9" cy="177.8" r="3.5" fill="#38BDF8"/><circle cx="421.1" cy="172.0" r="3.5" fill="#38BDF8"/><circle cx="515.4" cy="180.7" r="3.5" fill="#38BDF8"/><circle cx="609.7" cy="166.2" r="3.5" fill="#38BDF8"/><circle cx="704.0" cy="160.4" r="3.5" fill="#38BDF8"/><path d="M 44.0 200.9 L 138.3 198.0 L 232.6 203.8 L 326.9 195.1 L 421.1 192.2 L 515.4 198.0 L 609.7 189.3 L 704.0 186.4" fill="none" stroke="#34D399" stroke-width="2.5"/><circle cx="44.0" cy="200.9" r="3.5" fill="#34D399"/><circle cx="138.3" cy="198.0" r="3.5" fill="#34D399"/><circle cx="232.6" cy="203.8" r="3.5" fill="#34D399"/><circle cx="326.9" cy="195.1" r="3.5" fill="#34D399"/><circle cx="421.1" cy="192.2" r="3.5" fill="#34D399"/><circle cx="515.4" cy="198.0" r="3.5" fill="#34D399"/><circle cx="609.7" cy="189.3" r="3.5" fill="#34D399"/><circle cx="704.0" cy="186.4" r="3.5" fill="#34D399"/>
<text x="44.0" y="250" text-anchor="middle" fill="#6B7280" font-size="9">07-06</text><text x="138.3" y="250" text-anchor="middle" fill="#6B7280" font-size="9">07-13</text><text x="232.6" y="250" text-anchor="middle" fill="#6B7280" font-size="9">07-20</text><text x="326.9" y="250" text-anchor="middle" fill="#6B7280" font-size="9">07-27</text><text x="421.1" y="250" text-anchor="middle" fill="#6B7280" font-size="9">08-03</text><text x="515.4" y="250" text-anchor="middle" fill="#6B7280" font-size="9">08-10</text><text x="609.7" y="250" text-anchor="middle" fill="#6B7280" font-size="9">08-17</text><text x="704.0" y="250" text-anchor="middle" fill="#6B7280" font-size="9">08-24</text>
</svg>
<p class="preview-note">La gráfica usa series de ejemplo (Virginia aún no tiene kpi_weekly). El timeline inferior sí es sync real de Superhuman.</p>
</section>
<section style="margin-top:28px">
<strong style="font-size:15px">Timeline reunión a reunión <span style="color:#6B7280;font-weight:400">(12)</span></strong>
<ol>
<li>
<span class="dot"></span>
<article>
<div class="row">
<div>
<span class="pill" style="color:#34D399;border-color:#34D39955;background:#34D39922">Tema</span>
<span class="when">18 Aug 2026</span>
<h3>Gravitae: Meta Ads/Google ADs</h3>
</div>
<a href="https://docs.superhuman.com/d/_dqt1ZA3XaMp/_suvQLvnL" target="_blank">Superhuman ↗</a>
</div>
<p>- **[Landing Page del Practitioner en Coaching Ejecutivo](https://gravitaeacademy.com/es/diploma-en-coaching-ejecutivo/)**
📊 KPIs a vigilar las primeras 2 semanas
## Copy of T</p>
</article>
</li>
<li>
<span class="dot"></span>
<article>
<div class="row">
<div>
<span class="pill" style="color:#34D399;border-color:#34D39955;background:#34D39922">Tema</span>
<span class="when">01 Jul 2026</span>
<h3>wbf_mad_sice.pdf</h3>
</div>
<a href="https://docs.superhuman.com/d/_dqt1ZA3XaMp/_suJVY_uk" target="_blank">Superhuman ↗</a>
</div>
<p>Abrir en Superhuman para el detalle completo.</p>
</article>
</li>
<li>
<span class="dot"></span>
<article>
<div class="row">
<div>
<span class="pill" style="color:#34D399;border-color:#34D39955;background:#34D39922">Tema</span>
<span class="when">01 Jul 2026</span>
<h3>Info emails Open Event 9/7/26</h3>
</div>
<a href="https://docs.superhuman.com/d/_dqt1ZA3XaMp/_su8PImAd" target="_blank">Superhuman ↗</a>
</div>
<p>&gt; Añadimos a Carl Lewis que tiene mucho nombre
También quería compartir una oportunidad que puede ser de interés para quienes trabajáis en liderazgo, gestión de equipos o transfo</p>
</article>
</li>
<li>
<span class="dot"></span>
<article>
<div class="row">
<div>
<span class="pill" style="color:#34D399;border-color:#34D39955;background:#34D39922">Tema</span>
<span class="when">25 Jun 2026</span>
<h3>Email Open Event 9 julio.pdf</h3>
</div>
<a href="https://docs.superhuman.com/d/_dqt1ZA3XaMp/_suQBkgXM" target="_blank">Superhuman ↗</a>
</div>
<p>Abrir en Superhuman para el detalle completo.</p>
</article>
</li>
<li>
<span class="dot"></span>
<article>
<div class="row">
<div>
<span class="pill" style="color:#34D399;border-color:#34D39955;background:#34D39922">Tema</span>
<span class="when">18 Jun 2026</span>
<h3>Email 1</h3>
</div>
<a href="https://docs.superhuman.com/d/_dqt1ZA3XaMp/_suSUWITB" target="_blank">Superhuman ↗</a>
</div>
<p>1. EMAIL A TODOS LOS QUE SE HAN REGISTRADO AL EVENTO Y NO HAN IDO
TITULO: Ponte al día con lo que te perdiste en el I Salón Internacional de Coaching Ejecutivo
Hola NOMBRE,</p>
</article>
</li>
<li>
<span class="dot"></span>
<article>
<div class="row">
<div>
<span class="pill" style="color:#34D399;border-color:#34D39955;background:#34D39922">Tema</span>
<span class="when">08 Jun 2026</span>
<h3>WhatApp</h3>
</div>
<a href="https://docs.superhuman.com/d/_dqt1ZA3XaMp/_suguIU-Q" target="_blank">Superhuman ↗</a>
</div>
<p>- Hola nombre. Quedan 4 días, te esperamos el viernes a las 9:30 en el saloninternacionacionalcoaching.com., por favor confirma tu asistencia abajo.
- Confirmar Asistencia
- N</p>
</article>
</li>
<li>
<span class="dot"></span>
<article>
<div class="row">
<div>
<span class="pill" style="color:#34D399;border-color:#34D39955;background:#34D39922">Tema</span>
<span class="when">24 Apr 2026</span>
<h3>Secuencia emails 240426</h3>
</div>
<a href="https://docs.superhuman.com/d/_dqt1ZA3XaMp/_sulZ9VOu" target="_blank">Superhuman ↗</a>
</div>
<p>Es una [secuencia de 6 emails](https://drive.google.com/drive/folders/1i7717lRLgpgU5Y6FZonbbrpWjS3_pD96?usp=sharing), el primero para hoy si es posible y os ha dado tiempo a prepar</p>
</article>
</li>
<li>
<span class="dot"></span>
<article>
<div class="row">
<div>
<span class="pill" style="color:#34D399;border-color:#34D39955;background:#34D39922">Tema</span>
<span class="when">23 Apr 2026</span>
<h3>Web Salón V2</h3>
</div>
<a href="https://docs.superhuman.com/d/_dqt1ZA3XaMp/_sumI6wo-" target="_blank">Superhuman ↗</a>
</div>
<p>**Pestanas: Registro / Agenda / Organiza / Contacto**
### **AGENDA**
Te mando por aquí el texto para la agenda: primero poner un texto introductorio, luego los conferenciantes c</p>
</article>
</li>
</ol>
</section>
</main>
</div>
</body>
</html>

View File

@@ -0,0 +1,202 @@
#!/usr/bin/env npx tsx
/**
* Backfill deals.pipeline_id / pipeline_name / source / name from Clientify
* for one tenant so KPI Online filter + attribution work for MoM/YoY.
*
* TENANT_SLUG=bps npx tsx scripts/backfill_deal_pipelines.ts
*/
import { config } from 'dotenv';
config({ path: '.env.local' });
const CFY = 'https://api.clientify.com/v1';
const url = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const key = process.env.SUPABASE_SERVICE_ROLE_KEY!;
function envKey(slug: string) {
const s = slug.toUpperCase().replace(/-/g, '_');
return (
process.env[`CLIENTIFY_API_KEY_${s}`] ||
process.env.CLIENTIFY_API_KEY ||
''
);
}
function isPaidAds(s: string) {
const x = s.toLowerCase();
return (
/^(meta|facebook|instagram|google|google ads|google-ads|google_ads)/.test(
x
) ||
x.includes('facebook') ||
x.includes('google ads')
);
}
function normalizeSource(s: string | null | undefined): string | null {
const x = (s || '').toLowerCase().trim();
if (!x) return null;
if (isPaidAds(x)) return x.includes('google') ? 'google' : 'meta';
if (x.includes('blue parrot') || x.includes('woocommerce') || x === 'ecommerce')
return 'ecommerce';
if (x.includes('recomend') || x.includes('amigo')) return 'referral';
if (
x.includes('nivel') ||
x.includes('prueba') ||
x.startsWith('test') ||
x === 'general' ||
x === 'organic' ||
x === 'unknown' ||
x === 'import' ||
x === 'manual'
)
return 'organic';
if (x.startsWith('integrations')) return 'integrations-api';
return x.slice(0, 80);
}
function inferSource(d: {
deal_source?: string;
contact_source?: string;
tags?: string[];
name?: string;
}): string | null {
const fromDeal = d.deal_source || d.contact_source || '';
const tags = (d.tags || []).join(' ').toLowerCase();
if (isPaidAds(fromDeal)) return normalizeSource(fromDeal);
if (/facebook|instagram|\bmeta\b|\bfb\b/.test(tags)) return 'meta';
if (/google/.test(tags)) return 'google';
if (fromDeal) return normalizeSource(fromDeal);
if (/woocommerce|ecommerce/.test(tags) || /^order\s+\d+/i.test(d.name || ''))
return 'ecommerce';
return null;
}
async function main() {
const slug = (process.env.TENANT_SLUG || 'bps').trim();
const token = envKey(slug);
if (!token || !url || !key) throw new Error('missing env');
const tenants = await (
await fetch(
`${url}/rest/v1/tenants?slug=eq.${encodeURIComponent(slug)}&select=id,name`,
{ headers: { apikey: key, Authorization: `Bearer ${key}` } }
)
).json();
const tid = tenants[0]?.id as string;
if (!tid) throw new Error(`tenant ${slug} not found`);
console.log('tenant', tenants[0].name, tid);
// Load warehouse won deals needing backfill (or all)
const wh: {
id: string;
crm_deal_id: string;
lead_id: string | null;
pipeline_id: string | null;
}[] = [];
for (let offset = 0; offset < 100000; offset += 1000) {
const chunk = await (
await fetch(
`${url}/rest/v1/deals?tenant_id=eq.${tid}&stage=eq.won&select=id,crm_deal_id,lead_id,pipeline_id&order=won_at.desc.nullslast&limit=1000&offset=${offset}`,
{ headers: { apikey: key, Authorization: `Bearer ${key}` } }
)
).json();
if (!Array.isArray(chunk) || !chunk.length) break;
wh.push(...chunk);
if (chunk.length < 1000) break;
}
const byCrm = new Map(wh.map((d) => [d.crm_deal_id.replace(/^clientify:/, ''), d]));
console.log('warehouse won', wh.length);
// Lead sources for attribution priority
const leadSrc = new Map<string, string>();
const leadIds = [...new Set(wh.map((d) => d.lead_id).filter(Boolean))] as string[];
for (let i = 0; i < leadIds.length; i += 200) {
const ids = leadIds.slice(i, i + 200);
const rows = await (
await fetch(
`${url}/rest/v1/leads?id=in.(${ids.join(',')})&select=id,source`,
{ headers: { apikey: key, Authorization: `Bearer ${key}` } }
)
).json();
if (Array.isArray(rows)) {
for (const r of rows) if (r.source) leadSrc.set(r.id, r.source);
}
}
let page = 1;
let patched = 0;
let seen = 0;
while (page <= 200) {
const j = await (
await fetch(
`${CFY}/deals/?page_size=100&page=${page}&status=3&ordering=-actual_closed_date`,
{ headers: { Authorization: `Token ${token}` } }
)
).json();
const results = j.results || [];
if (!results.length) break;
for (const d of results) {
seen++;
const did = String(d.id);
const row = byCrm.get(did);
if (!row) continue;
const pipeUrl = String(d.pipeline || '');
const pipeline_id = pipeUrl.includes('/pipelines/')
? pipeUrl.replace(/\/$/, '').split('/').pop() || null
: null;
const pipeline_name = d.pipeline_desc || null;
const fromLead = row.lead_id ? leadSrc.get(row.lead_id) : null;
let source: string | null = null;
if (fromLead && isPaidAds(fromLead)) source = normalizeSource(fromLead);
else {
const inferred = inferSource(d);
if (inferred && isPaidAds(inferred)) source = inferred;
else if (fromLead) source = normalizeSource(fromLead);
else source = inferred;
}
if (
!source &&
(pipeline_id === '77547' || /online/i.test(pipeline_name || ''))
) {
source = 'ecommerce';
}
const r = await fetch(`${url}/rest/v1/deals?id=eq.${row.id}`, {
method: 'PATCH',
headers: {
apikey: key,
Authorization: `Bearer ${key}`,
'Content-Type': 'application/json',
Prefer: 'return=minimal',
},
body: JSON.stringify({
pipeline_id,
pipeline_name,
source,
name: d.name || null,
}),
});
if (r.ok) patched++;
}
const oldest = (results[results.length - 1].actual_closed_date || '').slice(
0,
10
);
console.log(`page ${page} seen=${seen} patched=${patched} oldest=${oldest}`);
if (!j.next) break;
// Stop if we've gone past all warehouse deals' likely range
if (oldest && oldest < '2024-01-01') break;
page++;
}
console.log('DONE', { seen, patched, warehouse: wh.length });
}
main().catch((e) => {
console.error(e);
process.exit(1);
});

View File

@@ -0,0 +1,96 @@
#!/usr/bin/env python3
"""Apply sql/ad_daily_leads_numeric.sql via Dokploy supabase-db terminal."""
from __future__ import annotations
import base64
import os
import re
import sys
from pathlib import Path
from playwright.sync_api import sync_playwright
ROOT = Path("/Users/4ventures/4ventures-client-portal")
BPS = Path("/Users/4ventures/Projects/bps-facturacion")
SQL = ROOT / "sql" / "ad_daily_leads_numeric.sql"
BASE = "https://dokploy.4ventures.es"
def env(key: str) -> str:
v = os.environ.get(key, "").strip()
if v:
return v
p = BPS / ".env"
if not p.is_file():
return ""
for line in p.read_text().splitlines():
if line.startswith(f"{key}="):
return line.split("=", 1)[1].strip().strip('"').strip("'")
return ""
def main() -> int:
email = env("DOKPLOY_EMAIL") or "marketing@4ventures.es"
password = env("DOKPLOY_PASSWORD")
if not password:
print("NO_PASSWORD", flush=True)
return 2
b64 = base64.b64encode(SQL.read_text(encoding="utf-8").encode()).decode()
chunks = [b64[i : i + 180] for i in range(0, len(b64), 180)]
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page(viewport={"width": 1400, "height": 1000})
page.goto(BASE + "/", wait_until="domcontentloaded", timeout=90000)
page.wait_for_timeout(800)
if "/dashboard" not in page.url:
page.locator('input[type="email"], input[name="email"]').first.fill(email)
page.locator('input[type="password"]').first.fill(password)
page.locator('button[type="submit"]').first.click()
page.wait_for_url(re.compile(r"/dashboard"), timeout=90000)
print("login ok", flush=True)
page.goto(f"{BASE}/dashboard/docker", wait_until="domcontentloaded", timeout=120000)
page.wait_for_timeout(800)
page.locator("input[placeholder*='ilter' i], input[placeholder*='earch' i]").first.fill(
"supabase-db"
)
page.wait_for_timeout(1000)
page.keyboard.press("Escape")
page.wait_for_timeout(200)
page.get_by_role("button", name=re.compile(r"Open menu", re.I)).first.click()
page.wait_for_timeout(400)
page.get_by_text("Terminal", exact=True).click()
page.wait_for_timeout(1000)
if page.locator("button", has_text="Bash").count():
page.locator("button", has_text="Bash").first.click()
page.wait_for_timeout(2800)
if page.locator("textarea.xterm-helper-textarea").count():
page.locator("textarea.xterm-helper-textarea").first.click()
def run(cmd: str, wait: int = 800) -> None:
page.keyboard.type(cmd, delay=1)
page.keyboard.press("Enter")
page.wait_for_timeout(wait)
run("rm -f /tmp/adl.b64 /tmp/adl.sql; echo READY", 1200)
for i, ch in enumerate(chunks):
op = ">" if i == 0 else ">>"
run(f"printf '%s' '{ch}' {op} /tmp/adl.b64", 400)
run(
"base64 -d /tmp/adl.b64 > /tmp/adl.sql && "
"psql -U postgres -d postgres -v ON_ERROR_STOP=1 -f /tmp/adl.sql; echo EXIT:$?",
8000,
)
text = page.evaluate(
"() => (document.querySelector('.xterm-rows')||{}).innerText||''"
)
browser.close()
ok = "EXIT:0" in text
print("SQL_OK", ok, flush=True)
if not ok:
print(text[-1500:], flush=True)
return 0 if ok else 6
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,103 @@
#!/usr/bin/env python3
"""Apply sql/ad_daily_link_clicks.sql via Dokploy supabase-db terminal."""
from __future__ import annotations
import base64
import os
import re
import sys
from pathlib import Path
from playwright.sync_api import sync_playwright
ROOT = Path("/Users/4ventures/4ventures-client-portal")
BPS = Path("/Users/4ventures/Projects/bps-facturacion")
SQL = ROOT / "sql" / "ad_daily_link_clicks.sql"
BASE = "https://dokploy.4ventures.es"
def env(key: str) -> str:
v = os.environ.get(key, "").strip()
if v:
return v
p = BPS / ".env"
if not p.is_file():
return ""
for line in p.read_text().splitlines():
if line.startswith(f"{key}="):
return line.split("=", 1)[1].strip().strip('"').strip("'")
return ""
def main() -> int:
email = env("DOKPLOY_EMAIL") or "marketing@4ventures.es"
password = env("DOKPLOY_PASSWORD")
if not password:
print("NO_PASSWORD", flush=True)
return 2
b64 = base64.b64encode(SQL.read_text(encoding="utf-8").encode()).decode()
chunks = [b64[i : i + 180] for i in range(0, len(b64), 180)]
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page(viewport={"width": 1400, "height": 1000})
page.goto(BASE + "/", wait_until="domcontentloaded", timeout=90000)
page.wait_for_timeout(800)
if "/dashboard" not in page.url:
page.locator('input[type="email"], input[name="email"]').first.fill(email)
page.locator('input[type="password"]').first.fill(password)
page.locator('button[type="submit"]').first.click()
page.wait_for_url(re.compile(r"/dashboard"), timeout=90000)
print("login ok", flush=True)
page.goto(f"{BASE}/dashboard/docker", wait_until="domcontentloaded", timeout=120000)
page.wait_for_timeout(800)
page.locator("input[placeholder*='ilter' i], input[placeholder*='earch' i]").first.fill(
"supabase-db"
)
page.wait_for_timeout(1000)
page.keyboard.press("Escape")
page.wait_for_timeout(200)
page.get_by_role("button", name=re.compile(r"Open menu", re.I)).first.click()
page.wait_for_timeout(400)
page.get_by_text("Terminal", exact=True).click()
page.wait_for_timeout(1000)
if page.locator("button", has_text="Bash").count():
page.locator("button", has_text="Bash").first.click()
page.wait_for_timeout(2800)
if page.locator("textarea.xterm-helper-textarea").count():
page.locator("textarea.xterm-helper-textarea").first.click()
def run(cmd: str, wait: int = 800) -> None:
page.keyboard.type(cmd, delay=1)
page.keyboard.press("Enter")
page.wait_for_timeout(wait)
run("rm -f /tmp/adlc.b64 /tmp/adlc.sql; echo READY", 1200)
for i, ch in enumerate(chunks):
op = ">" if i == 0 else ">>"
run(f"printf '%s' '{ch}' {op} /tmp/adlc.b64", 400)
run(
"base64 -d /tmp/adlc.b64 > /tmp/adlc.sql && "
"psql -U postgres -d postgres -v ON_ERROR_STOP=1 -f /tmp/adlc.sql; echo EXIT:$?",
12000,
)
run(
"psql -U postgres -d postgres -c "
"\"SELECT column_name,data_type FROM information_schema.columns "
"WHERE table_schema='public' AND table_name='ad_daily' "
"AND column_name='link_clicks';\" ; echo COL_DONE",
4000,
)
text = page.evaluate(
"() => (document.querySelector('.xterm-rows')||{}).innerText||''"
)
browser.close()
ok = "EXIT:0" in text and "link_clicks" in text
print("SQL_OK", ok, flush=True)
if not ok:
print(text[-2000:], flush=True)
return 0 if ok else 6
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,91 @@
#!/usr/bin/env python3
"""Apply sql/agency_control.sql via Dokploy supabase-db terminal."""
from __future__ import annotations
import base64
import os
import re
import sys
from pathlib import Path
from playwright.sync_api import sync_playwright
ROOT = Path("/Users/4ventures/4ventures-client-portal")
BPS = Path("/Users/4ventures/Projects/bps-facturacion")
SQL = ROOT / "sql" / "agency_control.sql"
BASE = "https://dokploy.4ventures.es"
def env(key: str) -> str:
v = os.environ.get(key, "").strip()
if v:
return v
p = BPS / ".env"
if not p.is_file():
return ""
for line in p.read_text().splitlines():
if line.startswith(f"{key}="):
return line.split("=", 1)[1].strip().strip('"').strip("'")
return ""
def main() -> int:
email = env("DOKPLOY_EMAIL") or "marketing@4ventures.es"
password = env("DOKPLOY_PASSWORD")
b64 = base64.b64encode(SQL.read_text(encoding="utf-8").encode()).decode()
chunks = [b64[i : i + 180] for i in range(0, len(b64), 180)]
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page(viewport={"width": 1400, "height": 1000})
page.goto(BASE + "/", wait_until="domcontentloaded", timeout=90000)
page.wait_for_timeout(800)
if "/dashboard" not in page.url:
page.locator('input[type="email"], input[name="email"]').first.fill(email)
page.locator('input[type="password"]').first.fill(password)
page.locator('button[type="submit"]').first.click()
page.wait_for_url(re.compile(r"/dashboard"), timeout=90000)
print("login ok", flush=True)
page.goto(f"{BASE}/dashboard/docker", wait_until="domcontentloaded", timeout=120000)
page.wait_for_timeout(800)
page.locator("input[placeholder*='ilter' i], input[placeholder*='earch' i]").first.fill(
"supabase-db"
)
page.wait_for_timeout(1000)
page.keyboard.press("Escape")
page.wait_for_timeout(200)
page.get_by_role("button", name=re.compile(r"Open menu", re.I)).first.click()
page.wait_for_timeout(400)
page.get_by_text("Terminal", exact=True).click()
page.wait_for_timeout(1000)
if page.locator("button", has_text="Bash").count():
page.locator("button", has_text="Bash").first.click()
page.wait_for_timeout(2800)
if page.locator("textarea.xterm-helper-textarea").count():
page.locator("textarea.xterm-helper-textarea").first.click()
def run(cmd: str, wait: int = 800) -> None:
page.keyboard.type(cmd, delay=1)
page.keyboard.press("Enter")
page.wait_for_timeout(wait)
run("rm -f /tmp/ac.b64 /tmp/ac.sql; echo READY", 1200)
for i, ch in enumerate(chunks):
op = ">" if i == 0 else ">>"
run(f"printf '%s' '{ch}' {op} /tmp/ac.b64", 400)
run(
"base64 -d /tmp/ac.b64 > /tmp/ac.sql && "
"psql -U postgres -d postgres -v ON_ERROR_STOP=1 -f /tmp/ac.sql; echo EXIT:$?",
8000,
)
text = page.evaluate(
"() => (document.querySelector('.xterm-rows')||{}).innerText||''"
)
browser.close()
ok = "EXIT:0" in text
print("SQL_OK", ok, flush=True)
return 0 if ok else 6
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,107 @@
#!/usr/bin/env python3
"""Apply sql/agency_portfolio.sql via Dokploy supabase-db terminal."""
from __future__ import annotations
import base64
import os
import re
import sys
from pathlib import Path
from playwright.sync_api import sync_playwright
ROOT = Path("/Users/4ventures/4ventures-client-portal")
BPS = Path("/Users/4ventures/Projects/bps-facturacion")
SQL = ROOT / "sql" / "agency_portfolio.sql"
SHOT = ROOT / "scripts" / "dokploy-shots"
BASE = "https://dokploy.4ventures.es"
def env(key: str) -> str:
v = os.environ.get(key, "").strip()
if v:
return v
p = BPS / ".env"
if not p.is_file():
return ""
for line in p.read_text().splitlines():
if line.startswith(f"{key}="):
return line.split("=", 1)[1].strip().strip('"').strip("'")
return ""
def main() -> int:
email = env("DOKPLOY_EMAIL") or "marketing@4ventures.es"
password = env("DOKPLOY_PASSWORD")
if not password:
print("NO_PASSWORD", flush=True)
return 2
SHOT.mkdir(parents=True, exist_ok=True)
b64 = base64.b64encode(SQL.read_text(encoding="utf-8").encode()).decode()
chunks = [b64[i : i + 180] for i in range(0, len(b64), 180)]
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page(viewport={"width": 1500, "height": 1100})
page.goto(BASE + "/", wait_until="domcontentloaded", timeout=90000)
page.wait_for_timeout(500)
if "/dashboard" not in page.url:
page.locator('input[type="email"], input[name="email"]').first.fill(email)
page.locator('input[type="password"]').first.fill(password)
page.locator('button[type="submit"]').first.click()
page.wait_for_url(re.compile(r"/dashboard"), timeout=90000)
print("login ok", flush=True)
page.goto(f"{BASE}/dashboard/docker", wait_until="domcontentloaded", timeout=120000)
page.wait_for_timeout(800)
page.locator("input[placeholder*='ilter' i], input[placeholder*='earch' i]").first.fill(
"supabase-db"
)
page.wait_for_timeout(1200)
page.keyboard.press("Escape")
page.wait_for_timeout(200)
page.get_by_role("button", name=re.compile(r"Open menu", re.I)).first.click()
page.wait_for_timeout(400)
page.get_by_text("Terminal", exact=True).click()
page.wait_for_timeout(1200)
# db image usually has bash
if page.locator("button", has_text="Bash").count():
page.locator("button", has_text="Bash").first.click()
page.wait_for_timeout(3000)
if page.locator("textarea.xterm-helper-textarea").count():
page.locator("textarea.xterm-helper-textarea").first.click()
def run(cmd: str, wait: int = 900) -> None:
page.keyboard.type(cmd, delay=1)
page.keyboard.press("Enter")
page.wait_for_timeout(wait)
run("rm -f /tmp/agency.b64 /tmp/agency.sql; echo READY", 1500)
for i, ch in enumerate(chunks):
op = ">" if i == 0 else ">>"
run(f"printf '%s' '{ch}' {op} /tmp/agency.b64", 500)
run(
"base64 -d /tmp/agency.b64 > /tmp/agency.sql && wc -c /tmp/agency.sql && "
"psql -U postgres -d postgres -v ON_ERROR_STOP=1 -f /tmp/agency.sql; echo EXIT:$?",
10000,
)
run(
"psql -U postgres -d postgres -c \"select count(*) as integrations from public.integrations;\" ; echo DONE",
4000,
)
text = page.evaluate(
"() => (document.querySelector('.xterm-rows')||{}).innerText||''"
)
page.screenshot(path=str(SHOT / "agency_sql.png"), full_page=True)
browser.close()
ok = "EXIT:0" in text
print("EXIT0", ok, flush=True)
for ln in text.splitlines():
if re.search(r"EXIT:|ERROR|integrations|DONE|READY", ln, re.I):
print(">", ln.strip()[:200], flush=True)
return 0 if ok else 6
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,127 @@
#!/usr/bin/env python3
"""Apply sql/asset_files.sql + ensure storage bucket via Dokploy / API."""
from __future__ import annotations
import base64
import json
import os
import re
import sys
import urllib.request
from pathlib import Path
from playwright.sync_api import sync_playwright
ROOT = Path("/Users/4ventures/4ventures-client-portal")
BPS = Path("/Users/4ventures/Projects/bps-facturacion")
SQL = ROOT / "sql" / "asset_files.sql"
BASE = "https://dokploy.4ventures.es"
def env(key: str) -> str:
v = os.environ.get(key, "").strip()
if v:
return v
p = BPS / ".env"
if not p.is_file():
return ""
for line in p.read_text().splitlines():
if line.startswith(f"{key}="):
return line.split("=", 1)[1].strip().strip('"').strip("'")
return ""
def main() -> int:
email = env("DOKPLOY_EMAIL") or "marketing@4ventures.es"
password = env("DOKPLOY_PASSWORD")
b64 = base64.b64encode(SQL.read_text(encoding="utf-8").encode()).decode()
chunks = [b64[i : i + 180] for i in range(0, len(b64), 180)]
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page(viewport={"width": 1400, "height": 1000})
page.goto(BASE + "/", wait_until="domcontentloaded", timeout=90000)
page.wait_for_timeout(800)
if "/dashboard" not in page.url:
page.locator('input[type="email"], input[name="email"]').first.fill(email)
page.locator('input[type="password"]').first.fill(password)
page.locator('button[type="submit"]').first.click()
page.wait_for_url(re.compile(r"/dashboard"), timeout=90000)
print("login ok", flush=True)
page.goto(f"{BASE}/dashboard/docker", wait_until="domcontentloaded", timeout=120000)
page.wait_for_timeout(800)
page.locator("input[placeholder*='ilter' i], input[placeholder*='earch' i]").first.fill(
"supabase-db"
)
page.wait_for_timeout(1000)
page.keyboard.press("Escape")
page.wait_for_timeout(200)
page.get_by_role("button", name=re.compile(r"Open menu", re.I)).first.click()
page.wait_for_timeout(400)
page.get_by_text("Terminal", exact=True).click()
page.wait_for_timeout(1000)
if page.locator("button", has_text="Bash").count():
page.locator("button", has_text="Bash").first.click()
page.wait_for_timeout(2800)
if page.locator("textarea.xterm-helper-textarea").count():
page.locator("textarea.xterm-helper-textarea").first.click()
def run(cmd: str, wait: int = 800) -> None:
page.keyboard.type(cmd, delay=1)
page.keyboard.press("Enter")
page.wait_for_timeout(wait)
run("rm -f /tmp/af.b64 /tmp/af.sql; echo READY", 1200)
for i, ch in enumerate(chunks):
op = ">" if i == 0 else ">>"
run(f"printf '%s' '{ch}' {op} /tmp/af.b64", 400)
run(
"base64 -d /tmp/af.b64 > /tmp/af.sql && "
"psql -U postgres -d postgres -v ON_ERROR_STOP=1 -f /tmp/af.sql; echo EXIT:$?",
9000,
)
text = page.evaluate(
"() => (document.querySelector('.xterm-rows')||{}).innerText||''"
)
browser.close()
ok = "EXIT:0" in text
print("SQL_OK", ok, flush=True)
# Create bucket via service role from .env.local
vals = {}
for ln in (ROOT / ".env.local").read_text().splitlines():
if "=" in ln and not ln.strip().startswith("#"):
k, v = ln.split("=", 1)
vals[k.strip()] = v.strip().strip('"').strip("'")
url = vals["NEXT_PUBLIC_SUPABASE_URL"].rstrip("/")
key = vals["SUPABASE_SERVICE_ROLE_KEY"]
req = urllib.request.Request(
url + "/storage/v1/bucket",
data=json.dumps(
{
"id": "tenant-assets",
"name": "tenant-assets",
"public": False,
"file_size_limit": 83886080,
}
).encode(),
headers={
"apikey": key,
"Authorization": f"Bearer {key}",
"Content-Type": "application/json",
},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=20) as r:
print("BUCKET", r.status, flush=True)
except urllib.error.HTTPError as e:
body = e.read().decode()[:200]
print("BUCKET", e.code, body, flush=True)
if e.code not in (409, 400) and "exist" not in body.lower():
return 5 if not ok else 0
return 0 if ok else 6
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,92 @@
#!/usr/bin/env python3
"""Apply sql/content_collab.sql via Dokploy supabase-db terminal."""
from __future__ import annotations
import base64
import os
import re
import sys
from pathlib import Path
from playwright.sync_api import sync_playwright
ROOT = Path("/Users/4ventures/4ventures-client-portal")
BPS = Path("/Users/4ventures/Projects/bps-facturacion")
SQL = ROOT / "sql" / "content_collab.sql"
BASE = "https://dokploy.4ventures.es"
def env(key: str) -> str:
v = os.environ.get(key, "").strip()
if v:
return v
p = BPS / ".env"
if not p.is_file():
return ""
for line in p.read_text().splitlines():
if line.startswith(f"{key}="):
return line.split("=", 1)[1].strip().strip('"').strip("'")
return ""
def main() -> int:
email = env("DOKPLOY_EMAIL") or "marketing@4ventures.es"
password = env("DOKPLOY_PASSWORD")
b64 = base64.b64encode(SQL.read_text(encoding="utf-8").encode()).decode()
chunks = [b64[i : i + 180] for i in range(0, len(b64), 180)]
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page(viewport={"width": 1400, "height": 1000})
page.goto(BASE + "/", wait_until="domcontentloaded", timeout=90000)
page.wait_for_timeout(800)
if "/dashboard" not in page.url:
page.locator('input[type="email"], input[name="email"]').first.fill(email)
page.locator('input[type="password"]').first.fill(password)
page.locator('button[type="submit"]').first.click()
page.wait_for_url(re.compile(r"/dashboard"), timeout=90000)
print("login ok", flush=True)
page.goto(f"{BASE}/dashboard/docker", wait_until="domcontentloaded", timeout=120000)
page.wait_for_timeout(800)
page.locator("input[placeholder*='ilter' i], input[placeholder*='earch' i]").first.fill(
"supabase-db"
)
page.wait_for_timeout(1000)
page.keyboard.press("Escape")
page.wait_for_timeout(200)
page.get_by_role("button", name=re.compile(r"Open menu", re.I)).first.click()
page.wait_for_timeout(400)
page.get_by_text("Terminal", exact=True).click()
page.wait_for_timeout(1000)
if page.locator("button", has_text="Bash").count():
page.locator("button", has_text="Bash").first.click()
page.wait_for_timeout(2800)
if page.locator("textarea.xterm-helper-textarea").count():
page.locator("textarea.xterm-helper-textarea").first.click()
def run(cmd: str, wait: int = 800) -> None:
page.keyboard.type(cmd, delay=1)
page.keyboard.press("Enter")
page.wait_for_timeout(wait)
run("rm -f /tmp/cc.b64 /tmp/cc.sql; echo READY", 1200)
for i, ch in enumerate(chunks):
op = ">" if i == 0 else ">>"
run(f"printf '%s' '{ch}' {op} /tmp/cc.b64", 400)
run(
"base64 -d /tmp/cc.b64 > /tmp/cc.sql && "
"psql -U postgres -d postgres -v ON_ERROR_STOP=1 -f /tmp/cc.sql; echo EXIT:$?",
14000,
)
text = page.evaluate(
"() => (document.querySelector('.xterm-rows')||{}).innerText||''"
)
browser.close()
ok = "EXIT:0" in text
print("SQL_OK", ok, flush=True)
print(text[-600:], flush=True)
return 0 if ok else 6
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,92 @@
#!/usr/bin/env python3
"""Apply sql/creatives_preview.sql via Dokploy supabase-db terminal."""
from __future__ import annotations
import base64
import os
import re
import sys
from pathlib import Path
from playwright.sync_api import sync_playwright
ROOT = Path("/Users/4ventures/4ventures-client-portal")
BPS = Path("/Users/4ventures/Projects/bps-facturacion")
SQL = ROOT / "sql" / "creatives_preview.sql"
BASE = "https://dokploy.4ventures.es"
def env(key: str) -> str:
v = os.environ.get(key, "").strip()
if v:
return v
p = BPS / ".env"
if not p.is_file():
return ""
for line in p.read_text().splitlines():
if line.startswith(f"{key}="):
return line.split("=", 1)[1].strip().strip('"').strip("'")
return ""
def main() -> int:
email = env("DOKPLOY_EMAIL") or "marketing@4ventures.es"
password = env("DOKPLOY_PASSWORD")
b64 = base64.b64encode(SQL.read_text(encoding="utf-8").encode()).decode()
chunks = [b64[i : i + 180] for i in range(0, len(b64), 180)]
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page(viewport={"width": 1400, "height": 1000})
page.goto(BASE + "/", wait_until="domcontentloaded", timeout=90000)
page.wait_for_timeout(800)
if "/dashboard" not in page.url:
page.locator('input[type="email"], input[name="email"]').first.fill(email)
page.locator('input[type="password"]').first.fill(password)
page.locator('button[type="submit"]').first.click()
page.wait_for_url(re.compile(r"/dashboard"), timeout=90000)
print("login ok", flush=True)
page.goto(f"{BASE}/dashboard/docker", wait_until="domcontentloaded", timeout=120000)
page.wait_for_timeout(800)
page.locator("input[placeholder*='ilter' i], input[placeholder*='earch' i]").first.fill(
"supabase-db"
)
page.wait_for_timeout(1000)
page.keyboard.press("Escape")
page.wait_for_timeout(200)
page.get_by_role("button", name=re.compile(r"Open menu", re.I)).first.click()
page.wait_for_timeout(400)
page.get_by_text("Terminal", exact=True).click()
page.wait_for_timeout(1000)
if page.locator("button", has_text="Bash").count():
page.locator("button", has_text="Bash").first.click()
page.wait_for_timeout(2800)
if page.locator("textarea.xterm-helper-textarea").count():
page.locator("textarea.xterm-helper-textarea").first.click()
def run(cmd: str, wait: int = 800) -> None:
page.keyboard.type(cmd, delay=1)
page.keyboard.press("Enter")
page.wait_for_timeout(wait)
run("rm -f /tmp/cc.b64 /tmp/cc.sql; echo READY", 1200)
for i, ch in enumerate(chunks):
op = ">" if i == 0 else ">>"
run(f"printf '%s' '{ch}' {op} /tmp/cc.b64", 400)
run(
"base64 -d /tmp/cc.b64 > /tmp/cc.sql && "
"psql -U postgres -d postgres -v ON_ERROR_STOP=1 -f /tmp/cc.sql; echo EXIT:$?",
14000,
)
text = page.evaluate(
"() => (document.querySelector('.xterm-rows')||{}).innerText||''"
)
browser.close()
ok = "EXIT:0" in text
print("SQL_OK", ok, flush=True)
print(text[-600:], flush=True)
return 0 if ok else 6
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,92 @@
#!/usr/bin/env python3
"""Apply sql/creatives_upload_pmax.sql via Dokploy supabase-db terminal."""
from __future__ import annotations
import base64
import os
import re
import sys
from pathlib import Path
from playwright.sync_api import sync_playwright
ROOT = Path("/Users/4ventures/4ventures-client-portal")
BPS = Path("/Users/4ventures/Projects/bps-facturacion")
SQL = ROOT / "sql" / "creatives_upload_pmax.sql"
BASE = "https://dokploy.4ventures.es"
def env(key: str) -> str:
v = os.environ.get(key, "").strip()
if v:
return v
p = BPS / ".env"
if not p.is_file():
return ""
for line in p.read_text().splitlines():
if line.startswith(f"{key}="):
return line.split("=", 1)[1].strip().strip('"').strip("'")
return ""
def main() -> int:
email = env("DOKPLOY_EMAIL") or "marketing@4ventures.es"
password = env("DOKPLOY_PASSWORD")
b64 = base64.b64encode(SQL.read_text(encoding="utf-8").encode()).decode()
chunks = [b64[i : i + 180] for i in range(0, len(b64), 180)]
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page(viewport={"width": 1400, "height": 1000})
page.goto(BASE + "/", wait_until="domcontentloaded", timeout=90000)
page.wait_for_timeout(800)
if "/dashboard" not in page.url:
page.locator('input[type="email"], input[name="email"]').first.fill(email)
page.locator('input[type="password"]').first.fill(password)
page.locator('button[type="submit"]').first.click()
page.wait_for_url(re.compile(r"/dashboard"), timeout=90000)
print("login ok", flush=True)
page.goto(f"{BASE}/dashboard/docker", wait_until="domcontentloaded", timeout=120000)
page.wait_for_timeout(800)
page.locator("input[placeholder*='ilter' i], input[placeholder*='earch' i]").first.fill(
"supabase-db"
)
page.wait_for_timeout(1000)
page.keyboard.press("Escape")
page.wait_for_timeout(200)
page.get_by_role("button", name=re.compile(r"Open menu", re.I)).first.click()
page.wait_for_timeout(400)
page.get_by_text("Terminal", exact=True).click()
page.wait_for_timeout(1000)
if page.locator("button", has_text="Bash").count():
page.locator("button", has_text="Bash").first.click()
page.wait_for_timeout(2800)
if page.locator("textarea.xterm-helper-textarea").count():
page.locator("textarea.xterm-helper-textarea").first.click()
def run(cmd: str, wait: int = 800) -> None:
page.keyboard.type(cmd, delay=1)
page.keyboard.press("Enter")
page.wait_for_timeout(wait)
run("rm -f /tmp/cc.b64 /tmp/cc.sql; echo READY", 1200)
for i, ch in enumerate(chunks):
op = ">" if i == 0 else ">>"
run(f"printf '%s' '{ch}' {op} /tmp/cc.b64", 400)
run(
"base64 -d /tmp/cc.b64 > /tmp/cc.sql && "
"psql -U postgres -d postgres -v ON_ERROR_STOP=1 -f /tmp/cc.sql; echo EXIT:$?",
14000,
)
text = page.evaluate(
"() => (document.querySelector('.xterm-rows')||{}).innerText||''"
)
browser.close()
ok = "EXIT:0" in text
print("SQL_OK", ok, flush=True)
print(text[-600:], flush=True)
return 0 if ok else 6
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,96 @@
#!/usr/bin/env python3
"""Apply sql/deals_pipeline.sql via Dokploy supabase-db terminal."""
from __future__ import annotations
import base64
import os
import re
import sys
from pathlib import Path
from playwright.sync_api import sync_playwright
ROOT = Path("/Users/4ventures/4ventures-client-portal")
BPS = Path("/Users/4ventures/Projects/bps-facturacion")
SQL = ROOT / "sql" / "deals_pipeline.sql"
BASE = "https://dokploy.4ventures.es"
def env(key: str) -> str:
v = os.environ.get(key, "").strip()
if v:
return v
p = BPS / ".env"
if not p.is_file():
return ""
for line in p.read_text().splitlines():
if line.startswith(f"{key}="):
return line.split("=", 1)[1].strip().strip('"').strip("'")
return ""
def main() -> int:
email = env("DOKPLOY_EMAIL") or "marketing@4ventures.es"
password = env("DOKPLOY_PASSWORD")
if not password:
print("NO_PASSWORD", flush=True)
return 2
b64 = base64.b64encode(SQL.read_text(encoding="utf-8").encode()).decode()
chunks = [b64[i : i + 180] for i in range(0, len(b64), 180)]
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page(viewport={"width": 1400, "height": 1000})
page.goto(BASE + "/", wait_until="domcontentloaded", timeout=90000)
page.wait_for_timeout(800)
if "/dashboard" not in page.url:
page.locator('input[type="email"], input[name="email"]').first.fill(email)
page.locator('input[type="password"]').first.fill(password)
page.locator('button[type="submit"]').first.click()
page.wait_for_url(re.compile(r"/dashboard"), timeout=90000)
print("login ok", flush=True)
page.goto(f"{BASE}/dashboard/docker", wait_until="domcontentloaded", timeout=120000)
page.wait_for_timeout(800)
page.locator("input[placeholder*='ilter' i], input[placeholder*='earch' i]").first.fill(
"supabase-db"
)
page.wait_for_timeout(1000)
page.keyboard.press("Escape")
page.wait_for_timeout(200)
page.get_by_role("button", name=re.compile(r"Open menu", re.I)).first.click()
page.wait_for_timeout(400)
page.get_by_text("Terminal", exact=True).click()
page.wait_for_timeout(1000)
if page.locator("button", has_text="Bash").count():
page.locator("button", has_text="Bash").first.click()
page.wait_for_timeout(2800)
if page.locator("textarea.xterm-helper-textarea").count():
page.locator("textarea.xterm-helper-textarea").first.click()
def run(cmd: str, wait: int = 800) -> None:
page.keyboard.type(cmd, delay=1)
page.keyboard.press("Enter")
page.wait_for_timeout(wait)
run("rm -f /tmp/dp.b64 /tmp/dp.sql; echo READY", 1200)
for i, ch in enumerate(chunks):
op = ">" if i == 0 else ">>"
run(f"printf '%s' '{ch}' {op} /tmp/dp.b64", 400)
run(
"base64 -d /tmp/dp.b64 > /tmp/dp.sql && "
"psql -U postgres -d postgres -v ON_ERROR_STOP=1 -f /tmp/dp.sql; echo EXIT:$?",
8000,
)
text = page.evaluate(
"() => (document.querySelector('.xterm-rows')||{}).innerText||''"
)
browser.close()
ok = "EXIT:0" in text
print("SQL_OK", ok, flush=True)
if not ok:
print(text[-1200:], flush=True)
return 0 if ok else 6
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,92 @@
#!/usr/bin/env python3
"""Apply sql/google_drive.sql via Dokploy supabase-db terminal."""
from __future__ import annotations
import base64
import os
import re
import sys
from pathlib import Path
from playwright.sync_api import sync_playwright
ROOT = Path("/Users/4ventures/4ventures-client-portal")
BPS = Path("/Users/4ventures/Projects/bps-facturacion")
SQL = ROOT / "sql" / "google_drive.sql"
BASE = "https://dokploy.4ventures.es"
def env(key: str) -> str:
v = os.environ.get(key, "").strip()
if v:
return v
p = BPS / ".env"
if not p.is_file():
return ""
for line in p.read_text().splitlines():
if line.startswith(f"{key}="):
return line.split("=", 1)[1].strip().strip('"').strip("'")
return ""
def main() -> int:
email = env("DOKPLOY_EMAIL") or "marketing@4ventures.es"
password = env("DOKPLOY_PASSWORD")
b64 = base64.b64encode(SQL.read_text(encoding="utf-8").encode()).decode()
chunks = [b64[i : i + 180] for i in range(0, len(b64), 180)]
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page(viewport={"width": 1400, "height": 1000})
page.goto(BASE + "/", wait_until="domcontentloaded", timeout=90000)
page.wait_for_timeout(800)
if "/dashboard" not in page.url:
page.locator('input[type="email"], input[name="email"]').first.fill(email)
page.locator('input[type="password"]').first.fill(password)
page.locator('button[type="submit"]').first.click()
page.wait_for_url(re.compile(r"/dashboard"), timeout=90000)
print("login ok", flush=True)
page.goto(f"{BASE}/dashboard/docker", wait_until="domcontentloaded", timeout=120000)
page.wait_for_timeout(800)
page.locator("input[placeholder*='ilter' i], input[placeholder*='earch' i]").first.fill(
"supabase-db"
)
page.wait_for_timeout(1000)
page.keyboard.press("Escape")
page.wait_for_timeout(200)
page.get_by_role("button", name=re.compile(r"Open menu", re.I)).first.click()
page.wait_for_timeout(400)
page.get_by_text("Terminal", exact=True).click()
page.wait_for_timeout(1000)
if page.locator("button", has_text="Bash").count():
page.locator("button", has_text="Bash").first.click()
page.wait_for_timeout(2800)
if page.locator("textarea.xterm-helper-textarea").count():
page.locator("textarea.xterm-helper-textarea").first.click()
def run(cmd: str, wait: int = 800) -> None:
page.keyboard.type(cmd, delay=1)
page.keyboard.press("Enter")
page.wait_for_timeout(wait)
run("rm -f /tmp/gd.b64 /tmp/gd.sql; echo READY", 1200)
for i, ch in enumerate(chunks):
op = ">" if i == 0 else ">>"
run(f"printf '%s' '{ch}' {op} /tmp/gd.b64", 400)
run(
"base64 -d /tmp/gd.b64 > /tmp/gd.sql && "
"psql -U postgres -d postgres -v ON_ERROR_STOP=1 -f /tmp/gd.sql; echo EXIT:$?",
12000,
)
text = page.evaluate(
"() => (document.querySelector('.xterm-rows')||{}).innerText||''"
)
browser.close()
ok = "EXIT:0" in text
print("SQL_OK", ok, flush=True)
print(text[-600:], flush=True)
return 0 if ok else 6
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,91 @@
#!/usr/bin/env python3
"""Apply sql/organic_geo.sql via Dokploy supabase-db terminal."""
from __future__ import annotations
import base64
import os
import re
import sys
from pathlib import Path
from playwright.sync_api import sync_playwright
ROOT = Path("/Users/4ventures/4ventures-client-portal")
BPS = Path("/Users/4ventures/Projects/bps-facturacion")
SQL = ROOT / "sql" / "organic_geo.sql"
BASE = "https://dokploy.4ventures.es"
def env(key: str) -> str:
v = os.environ.get(key, "").strip()
if v:
return v
p = BPS / ".env"
if not p.is_file():
return ""
for line in p.read_text().splitlines():
if line.startswith(f"{key}="):
return line.split("=", 1)[1].strip().strip('"').strip("'")
return ""
def main() -> int:
email = env("DOKPLOY_EMAIL") or "marketing@4ventures.es"
password = env("DOKPLOY_PASSWORD")
b64 = base64.b64encode(SQL.read_text(encoding="utf-8").encode()).decode()
chunks = [b64[i : i + 180] for i in range(0, len(b64), 180)]
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page(viewport={"width": 1400, "height": 1000})
page.goto(BASE + "/", wait_until="domcontentloaded", timeout=90000)
page.wait_for_timeout(800)
if "/dashboard" not in page.url:
page.locator('input[type="email"], input[name="email"]').first.fill(email)
page.locator('input[type="password"]').first.fill(password)
page.locator('button[type="submit"]').first.click()
page.wait_for_url(re.compile(r"/dashboard"), timeout=90000)
print("login ok", flush=True)
page.goto(f"{BASE}/dashboard/docker", wait_until="domcontentloaded", timeout=120000)
page.wait_for_timeout(800)
page.locator("input[placeholder*='ilter' i], input[placeholder*='earch' i]").first.fill(
"supabase-db"
)
page.wait_for_timeout(1000)
page.keyboard.press("Escape")
page.wait_for_timeout(200)
page.get_by_role("button", name=re.compile(r"Open menu", re.I)).first.click()
page.wait_for_timeout(400)
page.get_by_text("Terminal", exact=True).click()
page.wait_for_timeout(1000)
if page.locator("button", has_text="Bash").count():
page.locator("button", has_text="Bash").first.click()
page.wait_for_timeout(2800)
if page.locator("textarea.xterm-helper-textarea").count():
page.locator("textarea.xterm-helper-textarea").first.click()
def run(cmd: str, wait: int = 800) -> None:
page.keyboard.type(cmd, delay=1)
page.keyboard.press("Enter")
page.wait_for_timeout(wait)
run("rm -f /tmp/og.b64 /tmp/og.sql; echo READY", 1200)
for i, ch in enumerate(chunks):
op = ">" if i == 0 else ">>"
run(f"printf '%s' '{ch}' {op} /tmp/og.b64", 400)
run(
"base64 -d /tmp/og.b64 > /tmp/og.sql && "
"psql -U postgres -d postgres -v ON_ERROR_STOP=1 -f /tmp/og.sql; echo EXIT:$?",
8000,
)
text = page.evaluate(
"() => (document.querySelector('.xterm-rows')||{}).innerText||''"
)
browser.close()
ok = "EXIT:0" in text
print("SQL_OK", ok, flush=True)
return 0 if ok else 6
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,92 @@
#!/usr/bin/env python3
"""Apply sql/seguimiento.sql via Dokploy supabase-db terminal."""
from __future__ import annotations
import base64
import os
import re
import sys
from pathlib import Path
from playwright.sync_api import sync_playwright
ROOT = Path("/Users/4ventures/4ventures-client-portal")
BPS = Path("/Users/4ventures/Projects/bps-facturacion")
SQL = ROOT / "sql" / "seguimiento.sql"
BASE = "https://dokploy.4ventures.es"
def env(key: str) -> str:
v = os.environ.get(key, "").strip()
if v:
return v
p = BPS / ".env"
if not p.is_file():
return ""
for line in p.read_text().splitlines():
if line.startswith(f"{key}="):
return line.split("=", 1)[1].strip().strip('"').strip("'")
return ""
def main() -> int:
email = env("DOKPLOY_EMAIL") or "marketing@4ventures.es"
password = env("DOKPLOY_PASSWORD")
b64 = base64.b64encode(SQL.read_text(encoding="utf-8").encode()).decode()
chunks = [b64[i : i + 180] for i in range(0, len(b64), 180)]
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page(viewport={"width": 1400, "height": 1000})
page.goto(BASE + "/", wait_until="domcontentloaded", timeout=90000)
page.wait_for_timeout(800)
if "/dashboard" not in page.url:
page.locator('input[type="email"], input[name="email"]').first.fill(email)
page.locator('input[type="password"]').first.fill(password)
page.locator('button[type="submit"]').first.click()
page.wait_for_url(re.compile(r"/dashboard"), timeout=90000)
print("login ok", flush=True)
page.goto(f"{BASE}/dashboard/docker", wait_until="domcontentloaded", timeout=120000)
page.wait_for_timeout(800)
page.locator("input[placeholder*='ilter' i], input[placeholder*='earch' i]").first.fill(
"supabase-db"
)
page.wait_for_timeout(1000)
page.keyboard.press("Escape")
page.wait_for_timeout(200)
page.get_by_role("button", name=re.compile(r"Open menu", re.I)).first.click()
page.wait_for_timeout(400)
page.get_by_text("Terminal", exact=True).click()
page.wait_for_timeout(1000)
if page.locator("button", has_text="Bash").count():
page.locator("button", has_text="Bash").first.click()
page.wait_for_timeout(2800)
if page.locator("textarea.xterm-helper-textarea").count():
page.locator("textarea.xterm-helper-textarea").first.click()
def run(cmd: str, wait: int = 800) -> None:
page.keyboard.type(cmd, delay=1)
page.keyboard.press("Enter")
page.wait_for_timeout(wait)
run("rm -f /tmp/seg.b64 /tmp/seg.sql; echo READY", 1200)
for i, ch in enumerate(chunks):
op = ">" if i == 0 else ">>"
run(f"printf '%s' '{ch}' {op} /tmp/seg.b64", 400)
run(
"base64 -d /tmp/seg.b64 > /tmp/seg.sql && "
"psql -U postgres -d postgres -v ON_ERROR_STOP=1 -f /tmp/seg.sql; echo EXIT:$?",
14000,
)
text = page.evaluate(
"() => (document.querySelector('.xterm-rows')||{}).innerText||''"
)
browser.close()
ok = "EXIT:0" in text
print("SQL_OK", ok, flush=True)
print(text[-600:], flush=True)
return 0 if ok else 6
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,99 @@
#!/usr/bin/env python3
"""Apply sql/seo_audits.sql via Dokploy supabase-db terminal."""
from __future__ import annotations
import base64
import os
import re
import sys
from pathlib import Path
from playwright.sync_api import sync_playwright
ROOT = Path("/Users/4ventures/4ventures-client-portal")
BPS = Path("/Users/4ventures/Projects/bps-facturacion")
SQL = ROOT / "sql" / "seo_audits.sql"
BASE = "https://dokploy.4ventures.es"
def env(key: str) -> str:
v = os.environ.get(key, "").strip()
if v:
return v
p = BPS / ".env"
if not p.is_file():
return ""
for line in p.read_text().splitlines():
if line.startswith(f"{key}="):
return line.split("=", 1)[1].strip().strip('"').strip("'")
return ""
def main() -> int:
email = env("DOKPLOY_EMAIL") or "marketing@4ventures.es"
password = env("DOKPLOY_PASSWORD")
if not password:
print("missing DOKPLOY_PASSWORD", flush=True)
return 2
b64 = base64.b64encode(SQL.read_text(encoding="utf-8").encode()).decode()
chunks = [b64[i : i + 180] for i in range(0, len(b64), 180)]
with sync_playwright() as p:
browser = p.chromium.launch(
headless=True,
channel="chrome",
)
page = browser.new_page(viewport={"width": 1400, "height": 1000})
page.goto(BASE + "/", wait_until="domcontentloaded", timeout=90000)
page.wait_for_timeout(800)
if "/dashboard" not in page.url:
page.locator('input[type="email"], input[name="email"]').first.fill(email)
page.locator('input[type="password"]').first.fill(password)
page.locator('button[type="submit"]').first.click()
page.wait_for_url(re.compile(r"/dashboard"), timeout=90000)
print("login ok", flush=True)
page.goto(f"{BASE}/dashboard/docker", wait_until="domcontentloaded", timeout=120000)
page.wait_for_timeout(800)
page.locator("input[placeholder*='ilter' i], input[placeholder*='earch' i]").first.fill(
"supabase-db"
)
page.wait_for_timeout(1000)
page.keyboard.press("Escape")
page.wait_for_timeout(200)
page.get_by_role("button", name=re.compile(r"Open menu", re.I)).first.click()
page.wait_for_timeout(400)
page.get_by_text("Terminal", exact=True).click()
page.wait_for_timeout(1000)
if page.locator("button", has_text="Bash").count():
page.locator("button", has_text="Bash").first.click()
page.wait_for_timeout(2800)
if page.locator("textarea.xterm-helper-textarea").count():
page.locator("textarea.xterm-helper-textarea").first.click()
def run(cmd: str, wait: int = 800) -> None:
page.keyboard.type(cmd, delay=1)
page.keyboard.press("Enter")
page.wait_for_timeout(wait)
run("rm -f /tmp/sa.b64 /tmp/sa.sql; echo READY", 1200)
for i, ch in enumerate(chunks):
op = ">" if i == 0 else ">>"
run(f"printf '%s' '{ch}' {op} /tmp/sa.b64", 400)
run(
"base64 -d /tmp/sa.b64 > /tmp/sa.sql && "
"psql -U postgres -d postgres -v ON_ERROR_STOP=1 -f /tmp/sa.sql; echo EXIT:$?",
8000,
)
text = page.evaluate(
"() => (document.querySelector('.xterm-rows')||{}).innerText||''"
)
browser.close()
ok = "EXIT:0" in text
print("SQL_OK", ok, flush=True)
if not ok:
print(text[-2000:], flush=True)
return 0 if ok else 6
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,92 @@
#!/usr/bin/env python3
"""Apply sql/targets_budget.sql via Dokploy supabase-db terminal."""
from __future__ import annotations
import base64
import os
import re
import sys
from pathlib import Path
from playwright.sync_api import sync_playwright
ROOT = Path("/Users/4ventures/4ventures-client-portal")
BPS = Path("/Users/4ventures/Projects/bps-facturacion")
SQL = ROOT / "sql" / "targets_budget.sql"
BASE = "https://dokploy.4ventures.es"
def env(key: str) -> str:
v = os.environ.get(key, "").strip()
if v:
return v
p = BPS / ".env"
if not p.is_file():
return ""
for line in p.read_text().splitlines():
if line.startswith(f"{key}="):
return line.split("=", 1)[1].strip().strip('"').strip("'")
return ""
def main() -> int:
email = env("DOKPLOY_EMAIL") or "marketing@4ventures.es"
password = env("DOKPLOY_PASSWORD")
b64 = base64.b64encode(SQL.read_text(encoding="utf-8").encode()).decode()
chunks = [b64[i : i + 180] for i in range(0, len(b64), 180)]
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page(viewport={"width": 1400, "height": 1000})
page.goto(BASE + "/", wait_until="domcontentloaded", timeout=90000)
page.wait_for_timeout(800)
if "/dashboard" not in page.url:
page.locator('input[type="email"], input[name="email"]').first.fill(email)
page.locator('input[type="password"]').first.fill(password)
page.locator('button[type="submit"]').first.click()
page.wait_for_url(re.compile(r"/dashboard"), timeout=90000)
print("login ok", flush=True)
page.goto(f"{BASE}/dashboard/docker", wait_until="domcontentloaded", timeout=120000)
page.wait_for_timeout(800)
page.locator("input[placeholder*='ilter' i], input[placeholder*='earch' i]").first.fill(
"supabase-db"
)
page.wait_for_timeout(1000)
page.keyboard.press("Escape")
page.wait_for_timeout(200)
page.get_by_role("button", name=re.compile(r"Open menu", re.I)).first.click()
page.wait_for_timeout(400)
page.get_by_text("Terminal", exact=True).click()
page.wait_for_timeout(1000)
if page.locator("button", has_text="Bash").count():
page.locator("button", has_text="Bash").first.click()
page.wait_for_timeout(2800)
if page.locator("textarea.xterm-helper-textarea").count():
page.locator("textarea.xterm-helper-textarea").first.click()
def run(cmd: str, wait: int = 800) -> None:
page.keyboard.type(cmd, delay=1)
page.keyboard.press("Enter")
page.wait_for_timeout(wait)
run("rm -f /tmp/tb.b64 /tmp/tb.sql; echo READY", 1200)
for i, ch in enumerate(chunks):
op = ">" if i == 0 else ">>"
run(f"printf '%s' '{ch}' {op} /tmp/tb.b64", 400)
run(
"base64 -d /tmp/tb.b64 > /tmp/tb.sql && "
"psql -U postgres -d postgres -v ON_ERROR_STOP=1 -f /tmp/tb.sql; echo EXIT:$?",
14000,
)
text = page.evaluate(
"() => (document.querySelector('.xterm-rows')||{}).innerText||''"
)
browser.close()
ok = "EXIT:0" in text
print("SQL_OK", ok, flush=True)
print(text[-600:], flush=True)
return 0 if ok else 6
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,91 @@
#!/usr/bin/env python3
"""Apply sql/tenant_ficha.sql via Dokploy supabase-db terminal."""
from __future__ import annotations
import base64
import os
import re
import sys
from pathlib import Path
from playwright.sync_api import sync_playwright
ROOT = Path("/Users/4ventures/4ventures-client-portal")
BPS = Path("/Users/4ventures/Projects/bps-facturacion")
SQL = ROOT / "sql" / "tenant_ficha.sql"
BASE = "https://dokploy.4ventures.es"
def env(key: str) -> str:
v = os.environ.get(key, "").strip()
if v:
return v
p = BPS / ".env"
if not p.is_file():
return ""
for line in p.read_text().splitlines():
if line.startswith(f"{key}="):
return line.split("=", 1)[1].strip().strip('"').strip("'")
return ""
def main() -> int:
email = env("DOKPLOY_EMAIL") or "marketing@4ventures.es"
password = env("DOKPLOY_PASSWORD")
b64 = base64.b64encode(SQL.read_text(encoding="utf-8").encode()).decode()
chunks = [b64[i : i + 180] for i in range(0, len(b64), 180)]
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page(viewport={"width": 1400, "height": 1000})
page.goto(BASE + "/", wait_until="domcontentloaded", timeout=90000)
page.wait_for_timeout(800)
if "/dashboard" not in page.url:
page.locator('input[type="email"], input[name="email"]').first.fill(email)
page.locator('input[type="password"]').first.fill(password)
page.locator('button[type="submit"]').first.click()
page.wait_for_url(re.compile(r"/dashboard"), timeout=90000)
print("login ok", flush=True)
page.goto(f"{BASE}/dashboard/docker", wait_until="domcontentloaded", timeout=120000)
page.wait_for_timeout(800)
page.locator("input[placeholder*='ilter' i], input[placeholder*='earch' i]").first.fill(
"supabase-db"
)
page.wait_for_timeout(1000)
page.keyboard.press("Escape")
page.wait_for_timeout(200)
page.get_by_role("button", name=re.compile(r"Open menu", re.I)).first.click()
page.wait_for_timeout(400)
page.get_by_text("Terminal", exact=True).click()
page.wait_for_timeout(1000)
if page.locator("button", has_text="Bash").count():
page.locator("button", has_text="Bash").first.click()
page.wait_for_timeout(2800)
if page.locator("textarea.xterm-helper-textarea").count():
page.locator("textarea.xterm-helper-textarea").first.click()
def run(cmd: str, wait: int = 800) -> None:
page.keyboard.type(cmd, delay=1)
page.keyboard.press("Enter")
page.wait_for_timeout(wait)
run("rm -f /tmp/tf.b64 /tmp/tf.sql; echo READY", 1200)
for i, ch in enumerate(chunks):
op = ">" if i == 0 else ">>"
run(f"printf '%s' '{ch}' {op} /tmp/tf.b64", 400)
run(
"base64 -d /tmp/tf.b64 > /tmp/tf.sql && "
"psql -U postgres -d postgres -v ON_ERROR_STOP=1 -f /tmp/tf.sql; echo EXIT:$?",
8000,
)
text = page.evaluate(
"() => (document.querySelector('.xterm-rows')||{}).innerText||''"
)
browser.close()
ok = "EXIT:0" in text
print("SQL_OK", ok, flush=True)
return 0 if ok else 6
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,164 @@
#!/usr/bin/env python3
"""Apply Traefik harden patches (office IP allowlist). Does NOT touch portal.yml / app / supabase."""
from __future__ import annotations
import re
import sys
import time
import urllib.error
import urllib.request
from pathlib import Path
from playwright.sync_api import sync_playwright
BPS = Path("/Users/4ventures/Projects/bps-facturacion")
ROOT = Path("/Users/4ventures/4ventures-client-portal")
PATCH = ROOT / ".ops" / "traefik-patches"
SHOT = ROOT / "scripts" / "dokploy-shots"
BASE = "https://dokploy.4ventures.es"
# (ui label in Traefik file list, local patch file)
FILES = [
("middleware-hardening.yml", "middleware-hardening.yml"),
("gitea.yml", "gitea.yml"),
("n8n-hardened.yml", "n8n-hardened.yml"),
("rutas-manuales.yml", "rutas-manuales.yml"),
]
def env(key: str) -> str:
import os
v = os.environ.get(key, "").strip()
if v:
return v
p = BPS / ".env"
if not p.is_file():
return ""
for line in p.read_text(encoding="utf-8").splitlines():
if line.startswith(f"{key}="):
return line.split("=", 1)[1].strip().strip('"').strip("'")
return ""
def set_monaco(page, val: str) -> str:
mode = page.evaluate(
"""(val) => {
const m = window.monaco;
if (m && m.editor) {
const eds = m.editor.getEditors();
if (eds && eds[0]) { eds[0].setValue(val); return 'monaco'; }
}
const cm = document.querySelector('.cm-content');
if (cm) {
cm.focus();
return 'cm';
}
return 'none';
}""",
val,
)
if mode == "cm":
page.locator(".cm-content").first.click(force=True)
page.keyboard.press("Meta+A")
page.keyboard.insert_text(val)
return mode
def probe(url: str) -> tuple[int, str]:
try:
req = urllib.request.Request(url, method="GET", headers={"User-Agent": "4v-harden-probe"})
with urllib.request.urlopen(req, timeout=15) as r:
return r.status, (r.read(80) or b"").decode("utf-8", "replace")
except urllib.error.HTTPError as e:
return e.code, (e.read(80) or b"").decode("utf-8", "replace")
except Exception as e:
return 0, type(e).__name__
def main() -> int:
email = env("DOKPLOY_EMAIL") or "marketing@4ventures.es"
password = env("DOKPLOY_PASSWORD")
if not password:
print("NO_DOKPLOY_PASSWORD", flush=True)
return 2
SHOT.mkdir(parents=True, exist_ok=True)
for label, fname in FILES:
if not (PATCH / fname).is_file():
print("MISSING_PATCH", fname, flush=True)
return 3
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page(viewport={"width": 1600, "height": 1200})
page.goto(BASE + "/", wait_until="domcontentloaded", timeout=90000)
page.wait_for_timeout(700)
if "/dashboard" not in page.url:
page.locator('input[type="email"], input[name="email"]').first.fill(email)
page.locator('input[type="password"]').first.fill(password)
page.locator('button[type="submit"]').first.click()
page.wait_for_url(re.compile(r"/dashboard"), timeout=90000)
print("login ok", flush=True)
page.goto(f"{BASE}/dashboard/traefik", wait_until="networkidle", timeout=120000)
page.wait_for_timeout(1000)
page.get_by_text(re.compile(r"^dynamic$", re.I)).first.click()
page.wait_for_timeout(900)
for label, fname in FILES:
yaml = (PATCH / fname).read_text(encoding="utf-8")
# find file in list
loc = page.get_by_text(re.compile(rf"^{re.escape(label)}$", re.I))
if not loc.count():
loc = page.locator(f"text={label}")
if not loc.count():
print("FILE_MISSING_IN_UI", label, flush=True)
page.screenshot(path=str(SHOT / f"harden_missing_{fname}.png"), full_page=True)
browser.close()
return 4
loc.first.dblclick()
page.wait_for_timeout(1200)
unlock = page.get_by_role("button", name=re.compile(r"^Unlock$", re.I))
if unlock.count():
unlock.first.click()
page.wait_for_timeout(800)
mode = set_monaco(page, yaml)
print("set", label, mode, "chars", len(yaml), flush=True)
upd = page.get_by_role("button", name=re.compile(r"^Update$", re.I))
if not upd.count():
upd = page.get_by_role("button", name=re.compile(r"Update|Save", re.I))
upd.first.click()
page.wait_for_timeout(2000)
page.screenshot(path=str(SHOT / f"harden_applied_{fname.replace('.','_')}.png"), full_page=True)
# back to file list
page.goto(f"{BASE}/dashboard/traefik", wait_until="networkidle", timeout=120000)
page.wait_for_timeout(700)
page.get_by_text(re.compile(r"^dynamic$", re.I)).first.click()
page.wait_for_timeout(700)
browser.close()
time.sleep(3)
checks = [
("git", "https://git.4ventures.es/"),
("n8n_ui", "https://n8n.4ventures.es/"),
("n8n_agencia_ui", "https://n8n-agencia.4ventures.es/"),
("app", "https://app.4ventures.es/api/health"),
("dokploy", "https://dokploy.4ventures.es/"),
]
ok = True
for name, url in checks:
code, head = probe(url)
print(f"probe {name} {code} {head[:60]!r}", flush=True)
# From allowlisted IP: git/n8n should NOT be 403
if name in ("git", "n8n_ui", "n8n_agencia_ui") and code == 403:
ok = False
if name == "app" and code != 200:
ok = False
print("SUCCESS" if ok else "CHECK_FAILED", flush=True)
return 0 if ok else 5
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,89 @@
#!/usr/bin/env python3
"""Dump Traefik gitea.yml + n8n-hardened.yml + middlewares for harden prep."""
from __future__ import annotations
import re
import sys
from pathlib import Path
from playwright.sync_api import sync_playwright
BPS = Path("/Users/4ventures/Projects/bps-facturacion")
OUT = Path("/Users/4ventures/4ventures-client-portal/.ops/traefik-dumps")
SHOT = Path("/Users/4ventures/4ventures-client-portal/scripts/dokploy-shots")
BASE = "https://dokploy.4ventures.es"
FILES = ("middlewares.yml", "middleware-hardening.yml", "gitea.yml", "n8n-hardened.yml")
def env(key: str) -> str:
import os
v = os.environ.get(key, "").strip()
if v:
return v
p = BPS / ".env"
if not p.is_file():
return ""
for line in p.read_text(encoding="utf-8").splitlines():
if line.startswith(f"{key}="):
return line.split("=", 1)[1].strip().strip('"').strip("'")
return ""
def main() -> int:
OUT.mkdir(parents=True, exist_ok=True)
email = env("DOKPLOY_EMAIL") or "marketing@4ventures.es"
password = env("DOKPLOY_PASSWORD")
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page(viewport={"width": 1600, "height": 1200})
page.goto(BASE + "/", wait_until="domcontentloaded", timeout=90000)
page.wait_for_timeout(700)
if "/dashboard" not in page.url:
page.locator('input[type="email"], input[name="email"]').first.fill(email)
page.locator('input[type="password"]').first.fill(password)
page.locator('button[type="submit"]').first.click()
page.wait_for_url(re.compile(r"/dashboard"), timeout=90000)
print("login ok", flush=True)
page.goto(f"{BASE}/dashboard/traefik", wait_until="networkidle", timeout=120000)
page.wait_for_timeout(1000)
page.get_by_text(re.compile(r"^dynamic$", re.I)).first.click()
page.wait_for_timeout(800)
for name in FILES:
page.locator(f"text={name}").first.dblclick()
page.wait_for_timeout(1500)
# Unlock not needed for read; try extract from body line numbers area
body = page.inner_text("body")
# Heuristic: capture from 'http:' or 'http\n' yaml start
m = re.search(r"(?ms)^(http:|tcp:|udp:|tls:).*(?=Unlock|Skip YAML|Update\n|$)", body)
# Better: get cm content
text = page.evaluate(
"""() => {
const rows = document.querySelector('.cm-content');
return rows ? rows.innerText : '';
}"""
)
if not text or len(text) < 20:
# fallback strip UI chrome
lines = []
capture = False
for ln in body.splitlines():
if ln.strip() in ("http:", "tcp:", "tls:") or ln.startswith("http:"):
capture = True
if capture:
if ln.strip() in ("Unlock", "Update", "Skip YAML validation (for Go templating)"):
break
lines.append(ln)
text = "\n".join(lines)
(OUT / name).write_text(text + "\n", encoding="utf-8")
print("dumped", name, "chars", len(text), flush=True)
page.screenshot(path=str(SHOT / f"dump_{name.replace('.','_')}.png"), full_page=True)
browser.close()
return 0
if __name__ == "__main__":
raise SystemExit(main())

215
scripts/dokploy_env_api.py Normal file
View File

@@ -0,0 +1,215 @@
#!/usr/bin/env python3
"""Set client-portal env via Dokploy authenticated fetch (in-page), then Deploy."""
from __future__ import annotations
import json
import re
import sys
import time
import urllib.request
from pathlib import Path
from playwright.sync_api import sync_playwright
ROOT = Path("/Users/4ventures/4ventures-client-portal")
BPS = Path("/Users/4ventures/Projects/bps-facturacion")
SHOT = ROOT / "scripts/dokploy-shots"
BASE = "https://dokploy.4ventures.es"
APP_ID = "NZS797t9Rnq9Pj4mHiii3"
APP = f"{BASE}/dashboard/project/Z_4M-uDOH1_--itGSTh4l/environment/bGja70l_a7HQ1dxyXqSuf/services/application/{APP_ID}"
HEALTH = "https://app.4ventures.es/api/health"
def dokploy_cred(key: str) -> str:
import os
v = os.environ.get(key, "").strip()
if v:
return v
p = BPS / ".env"
if not p.is_file():
return ""
for line in p.read_text(encoding="utf-8").splitlines():
if line.startswith(f"{key}="):
return line.split("=", 1)[1].strip().strip('"').strip("'")
return ""
def load_env_text() -> str:
vals: dict[str, str] = {}
for line in (ROOT / ".env.local").read_text(encoding="utf-8").splitlines():
if not line.strip() or line.strip().startswith("#") or "=" not in line:
continue
k, v = line.split("=", 1)
vals[k.strip()] = v.strip().strip('"').strip("'")
parts = []
for k in (
"NEXT_PUBLIC_SUPABASE_URL",
"NEXT_PUBLIC_SUPABASE_ANON_KEY",
"SUPABASE_SERVICE_ROLE_KEY",
):
if not vals.get(k):
raise SystemExit(f"missing {k}")
parts.append(f"{k}={vals[k]}")
return "\n".join(parts) + "\n"
def main() -> int:
env_text = load_env_text()
email = dokploy_cred("DOKPLOY_EMAIL") or "marketing@4ventures.es"
password = dokploy_cred("DOKPLOY_PASSWORD")
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page(viewport={"width": 1500, "height": 1200})
page.goto(BASE + "/", wait_until="domcontentloaded", timeout=90000)
page.wait_for_timeout(800)
if "/dashboard" not in page.url:
page.locator('input[type="email"], input[name="email"]').first.fill(email)
page.locator('input[type="password"]').first.fill(password)
page.locator('button[type="submit"]').first.click()
page.wait_for_url(re.compile(r"/dashboard"), timeout=90000)
print("login ok", flush=True)
# Discover API by probing common Dokploy endpoints from authenticated page
page.goto(APP, wait_until="networkidle", timeout=120000)
page.wait_for_timeout(1000)
probe = page.evaluate(
"""async ({ appId, env }) => {
const tries = [];
const payloads = [
{ path: '/api/application.update', body: { applicationId: appId, env } },
{ path: '/api/application.update', body: { applicationId: appId, applicationStatus: 'done', env } },
{ path: '/api/trpc/application.update', body: { json: { applicationId: appId, env } } },
{ path: '/api/trpc/application.saveEnvironment?batch=1', body: { '0': { json: { applicationId: appId, env } } } },
{ path: '/api/trpc/application.update?batch=1', body: { '0': { json: { applicationId: appId, env } } } },
];
for (const t of payloads) {
try {
const r = await fetch(t.path, {
method: 'POST',
credentials: 'include',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(t.body),
});
const text = await r.text();
tries.push({ path: t.path, status: r.status, body: text.slice(0, 240) });
if (r.ok) return { ok: true, tries };
} catch (e) {
tries.push({ path: t.path, error: String(e) });
}
}
// Also GET one application to learn shape
try {
const r = await fetch('/api/trpc/application.one?batch=1&input=' + encodeURIComponent(JSON.stringify({ '0': { json: { applicationId: appId } } })), { credentials: 'include' });
tries.push({ path: 'application.one', status: r.status, body: (await r.text()).slice(0, 500) });
} catch (e) {
tries.push({ path: 'application.one', error: String(e) });
}
return { ok: false, tries };
}""",
{"appId": APP_ID, "env": env_text},
)
print(json.dumps(probe, indent=2)[:4000], flush=True)
if not probe.get("ok"):
# UI-only: careful eye click inside Environment Settings card
page.goto(APP + "?tab=environment", wait_until="networkidle", timeout=120000)
page.wait_for_timeout(1200)
page.screenshot(path=str(SHOT / "env_api_fail.png"), full_page=True)
# Click ONLY the eye in the Environment Settings header row
clicked = page.evaluate(
"""() => {
const labels = [...document.querySelectorAll('*')].filter(el => el.childNodes.length && [...el.childNodes].some(n => n.nodeType===3 && n.textContent.trim()==='Environment Settings'));
for (const label of labels) {
let root = label;
for (let i=0;i<6;i++) {
root = root.parentElement;
if (!root) break;
const btns = [...root.querySelectorAll('button')].filter(b => b.querySelector('svg') && !b.innerText.trim());
if (btns.length) { btns[0].click(); return true; }
}
}
return false;
}"""
)
print("careful eye", clicked, flush=True)
page.wait_for_timeout(800)
page.screenshot(path=str(SHOT / "env_careful.png"), full_page=True)
# fill first textarea or contenteditable
filled = page.evaluate(
"""(val) => {
const tas = [...document.querySelectorAll('textarea')];
if (tas.length) {
const ta = tas[0];
const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set;
setter.call(ta, val);
ta.dispatchEvent(new Event('input', { bubbles: true }));
ta.dispatchEvent(new Event('change', { bubbles: true }));
return 'textarea';
}
const cm = document.querySelector('.cm-content');
if (cm) {
cm.setAttribute('contenteditable','true');
return 'cm-present';
}
return 'none';
}""",
env_text,
)
print("filled", filled, flush=True)
if filled == "cm-present":
page.locator(".cm-content").first.click(force=True)
page.keyboard.press("Meta+A")
page.keyboard.insert_text(env_text)
save = page.get_by_role("button", name=re.compile(r"^Save$", re.I))
if save.count():
save.first.click(force=True)
page.wait_for_timeout(2000)
print("saved ui", flush=True)
# Deploy regardless if API ok
page.goto(APP, wait_until="networkidle", timeout=120000)
page.wait_for_timeout(800)
page.get_by_role("tab", name=re.compile(r"^General$", re.I)).first.click()
page.wait_for_timeout(500)
page.get_by_role("button", name=re.compile(r"^Deploy$", re.I)).first.click()
page.wait_for_timeout(700)
conf = page.get_by_role("button", name=re.compile(r"^Confirm$", re.I))
if conf.count():
conf.first.click()
print("DEPLOY_CLICKED", flush=True)
page.get_by_role("tab", name=re.compile(r"^Deployments$", re.I)).first.click()
deadline = time.time() + 780
while time.time() < deadline:
page.reload(wait_until="networkidle")
body = page.inner_text("body")
m = re.search(r"1\.\s*(Error|Done|Running)", body)
status = m.group(1) if m else "?"
print("status", status, flush=True)
if status == "Done":
break
if status == "Error":
browser.close()
return 6
time.sleep(15)
browser.close()
for _ in range(30):
try:
with urllib.request.urlopen(HEALTH, timeout=12) as r:
body = r.read().decode()
print("health", body[:220], flush=True)
if '"serviceRoleConfigured":true' in body:
print("SUCCESS", flush=True)
return 0
except Exception as e:
print("wait", type(e).__name__, flush=True)
time.sleep(6)
print("PARTIAL", flush=True)
return 5
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,90 @@
#!/usr/bin/env python3
"""Run RLS checklist query in supabase-db; print table/RLS summary."""
from __future__ import annotations
import re
import sys
from pathlib import Path
from playwright.sync_api import sync_playwright
ROOT = Path("/Users/4ventures/4ventures-client-portal")
BPS = Path("/Users/4ventures/Projects/bps-facturacion")
SHOT = ROOT / "scripts/dokploy-shots"
BASE = "https://dokploy.4ventures.es"
OUT = ROOT / ".ops" / "rls_inventory.txt"
QUERY = r"""
SELECT c.relname AS table_name, c.relrowsecurity AS rls_enabled
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = 'public' AND c.relkind = 'r'
ORDER BY 1;
"""
def env(key: str) -> str:
import os
v = os.environ.get(key, "").strip()
if v:
return v
p = BPS / ".env"
if not p.is_file():
return ""
for line in p.read_text(encoding="utf-8").splitlines():
if line.startswith(f"{key}="):
return line.split("=", 1)[1].strip().strip('"').strip("'")
return ""
def main() -> int:
email = env("DOKPLOY_EMAIL") or "marketing@4ventures.es"
password = env("DOKPLOY_PASSWORD")
# compact to one line for psql -c
q = " ".join(QUERY.split())
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page(viewport={"width": 1600, "height": 1200})
page.goto(BASE + "/", wait_until="domcontentloaded", timeout=90000)
page.wait_for_timeout(700)
if "/dashboard" not in page.url:
page.locator('input[type="email"], input[name="email"]').first.fill(email)
page.locator('input[type="password"]').first.fill(password)
page.locator('button[type="submit"]').first.click()
page.wait_for_url(re.compile(r"/dashboard"), timeout=90000)
page.goto(f"{BASE}/dashboard/docker", wait_until="networkidle", timeout=120000)
page.wait_for_timeout(1000)
page.locator("input[placeholder*='ilter' i], input[placeholder*='earch' i]").first.fill("supabase-db")
page.wait_for_timeout(1000)
page.get_by_role("button", name=re.compile(r"Open menu", re.I)).first.click()
page.wait_for_timeout(500)
page.get_by_text(re.compile(r"^Terminal$", re.I)).first.click()
page.wait_for_timeout(3000)
helper = page.locator("textarea.xterm-helper-textarea")
if helper.count():
helper.first.click()
cmd = f"psql -U postgres -d postgres -c \"{q}\" ; echo RLS_DONE"
page.keyboard.type(cmd, delay=2)
page.keyboard.press("Enter")
page.wait_for_timeout(6000)
page.screenshot(path=str(SHOT / "rls_term.png"), full_page=True)
text = page.evaluate(
"""() => {
const rows = document.querySelector('.xterm-rows');
return rows ? rows.innerText : '';
}"""
)
OUT.parent.mkdir(exist_ok=True)
OUT.write_text(text or "", encoding="utf-8")
print(text[-2500:] if text else "NO_TEXT", flush=True)
browser.close()
print("wrote", OUT, flush=True)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,159 @@
#!/usr/bin/env python3
"""Merge CRON_SECRET into client-portal Dokploy env, then Deploy."""
from __future__ import annotations
import json
import re
import sys
import time
from pathlib import Path
from playwright.sync_api import sync_playwright
ROOT = Path("/Users/4ventures/4ventures-client-portal")
BPS = Path("/Users/4ventures/Projects/bps-facturacion")
BASE = "https://dokploy.4ventures.es"
APP_ID = "NZS797t9Rnq9Pj4mHiii3"
APP = (
f"{BASE}/dashboard/project/Z_4M-uDOH1_--itGSTh4l"
f"/environment/bGja70l_a7HQ1dxyXqSuf/services/application/{APP_ID}"
)
HEALTH = "https://app.4ventures.es/api/health"
def cred(key: str) -> str:
import os
v = os.environ.get(key, "").strip()
if v:
return v
for p in (BPS / ".env", ROOT / ".env.local"):
if not p.is_file():
continue
for line in p.read_text(encoding="utf-8").splitlines():
if line.startswith(f"{key}="):
return line.split("=", 1)[1].strip().strip('"').strip("'")
return ""
def main() -> int:
cron = cred("CRON_SECRET")
if not cron:
print("missing CRON_SECRET locally", flush=True)
return 2
email = cred("DOKPLOY_EMAIL") or "marketing@4ventures.es"
password = cred("DOKPLOY_PASSWORD")
if not password:
print("missing DOKPLOY_PASSWORD", flush=True)
return 2
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page(viewport={"width": 1400, "height": 1000})
page.goto(BASE + "/", wait_until="domcontentloaded", timeout=90000)
page.wait_for_timeout(600)
if "/dashboard" not in page.url:
page.locator('input[type="email"], input[name="email"]').first.fill(email)
page.locator('input[type="password"]').first.fill(password)
page.locator('button[type="submit"]').first.click()
page.wait_for_url(re.compile(r"/dashboard"), timeout=90000)
print("login ok", flush=True)
page.goto(APP, wait_until="networkidle", timeout=120000)
page.wait_for_timeout(800)
result = page.evaluate(
"""async ({ appId, cronSecret }) => {
const oneUrl = '/api/trpc/application.one?batch=1&input=' + encodeURIComponent(JSON.stringify({ '0': { json: { applicationId: appId } } }));
const one = await fetch(oneUrl, { credentials: 'include' });
const oneText = await one.text();
if (!one.ok) return { ok: false, step: 'one', status: one.status, body: oneText.slice(0, 400) };
let envText = '';
try {
const j = JSON.parse(oneText);
const data = j[0]?.result?.data?.json || j[0]?.result?.data || j;
envText = data?.env || data?.environment || '';
if (typeof envText !== 'string') envText = '';
} catch (e) {
return { ok: false, step: 'parse', error: String(e), body: oneText.slice(0, 400) };
}
const lines = envText.split('\\n').filter(Boolean);
const map = {};
for (const line of lines) {
const i = line.indexOf('=');
if (i < 0) continue;
map[line.slice(0, i)] = line.slice(i + 1);
}
map['CRON_SECRET'] = cronSecret;
const merged = Object.keys(map).map(k => k + '=' + map[k]).join('\\n') + '\\n';
const keys = Object.keys(map);
const tries = [];
const payloads = [
{ path: '/api/application.update', body: { applicationId: appId, env: merged } },
{ path: '/api/trpc/application.update?batch=1', body: { '0': { json: { applicationId: appId, env: merged } } } },
{ path: '/api/trpc/application.saveEnvironment?batch=1', body: { '0': { json: { applicationId: appId, env: merged } } } },
];
for (const t of payloads) {
const r = await fetch(t.path, {
method: 'POST',
credentials: 'include',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(t.body),
});
const text = await r.text();
tries.push({ path: t.path, status: r.status, body: text.slice(0, 180) });
if (r.ok) return { ok: true, keys, tries, hadCron: keys.includes('CRON_SECRET') };
}
return { ok: false, keys, tries, envLen: envText.length };
}""",
{"appId": APP_ID, "cronSecret": cron},
)
print(
json.dumps(
{
"ok": result.get("ok"),
"hadCron": result.get("hadCron"),
"keyCount": len(result.get("keys") or []),
"tries": result.get("tries"),
"step": result.get("step"),
"status": result.get("status"),
},
indent=2,
)[:2000],
flush=True,
)
if not result.get("ok"):
browser.close()
return 6
page.goto(APP, wait_until="domcontentloaded", timeout=90000)
page.wait_for_timeout(700)
page.get_by_role("tab", name=re.compile(r"^General$", re.I)).first.click()
page.wait_for_timeout(500)
page.get_by_role("button", name=re.compile(r"^Deploy$", re.I)).first.click()
page.wait_for_timeout(600)
conf = page.get_by_role("button", name=re.compile(r"^Confirm$", re.I))
if conf.count():
conf.first.click()
print("DEPLOY_CLICKED", flush=True)
for i in range(30):
time.sleep(10)
try:
import urllib.request
code = urllib.request.urlopen(HEALTH, timeout=12).getcode()
except Exception as e:
code = str(e)
print(f"health_try{i}", code, flush=True)
if code == 200 and i >= 6:
# give container time to pick new env after first healthy
break
browser.close()
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,121 @@
#!/usr/bin/env python3
"""Merge GOOGLE_ADS_LOGIN_CUSTOMER_ID into client-portal Dokploy env."""
from __future__ import annotations
import re
import sys
from pathlib import Path
from playwright.sync_api import sync_playwright
ROOT = Path("/Users/4ventures/4ventures-client-portal")
BPS = Path("/Users/4ventures/Projects/bps-facturacion")
BASE = "https://dokploy.4ventures.es"
APP_ID = "NZS797t9Rnq9Pj4mHiii3"
APP = (
f"{BASE}/dashboard/project/Z_4M-uDOH1_--itGSTh4l"
f"/environment/bGja70l_a7HQ1dxyXqSuf/services/application/{APP_ID}"
)
KEY = "GOOGLE_ADS_LOGIN_CUSTOMER_ID"
VALUE = "5963150101"
def cred(key: str) -> str:
import os
v = os.environ.get(key, "").strip()
if v:
return v
p = BPS / ".env"
if not p.is_file():
return ""
for line in p.read_text(encoding="utf-8").splitlines():
if line.startswith(f"{key}="):
return line.split("=", 1)[1].strip().strip('"').strip("'")
return ""
def main() -> int:
email = cred("DOKPLOY_EMAIL") or "marketing@4ventures.es"
password = cred("DOKPLOY_PASSWORD")
if not password:
print("missing DOKPLOY_PASSWORD", flush=True)
return 2
chromium = Path.home() / "Library/Caches/ms-playwright/chromium-1148/chrome-mac/Chromium.app/Contents/MacOS/Chromium"
launch_kwargs = {"headless": True}
if chromium.is_file():
launch_kwargs["executable_path"] = str(chromium)
with sync_playwright() as p:
browser = p.chromium.launch(**launch_kwargs)
page = browser.new_page(viewport={"width": 1400, "height": 1000})
page.goto(BASE + "/", wait_until="domcontentloaded", timeout=90000)
page.wait_for_timeout(600)
if "/dashboard" not in page.url:
page.locator('input[type="email"], input[name="email"]').first.fill(email)
page.locator('input[type="password"]').first.fill(password)
page.locator('button[type="submit"]').first.click()
page.wait_for_url(re.compile(r"/dashboard"), timeout=90000)
print("login ok", flush=True)
page.goto(APP, wait_until="networkidle", timeout=120000)
page.wait_for_timeout(800)
result = page.evaluate(
"""async ({ appId, key, value }) => {
const oneUrl = '/api/trpc/application.one?batch=1&input=' + encodeURIComponent(JSON.stringify({ '0': { json: { applicationId: appId } } }));
const one = await fetch(oneUrl, { credentials: 'include' });
const oneText = await one.text();
if (!one.ok) return { ok: false, step: 'one', status: one.status, body: oneText.slice(0, 400) };
let envText = '';
try {
const j = JSON.parse(oneText);
const data = j[0]?.result?.data?.json || j[0]?.result?.data || j;
envText = data?.env || data?.environment || '';
if (typeof envText !== 'string') envText = '';
} catch (e) {
return { ok: false, step: 'parse', error: String(e), body: oneText.slice(0, 400) };
}
const lines = envText.split('\\n').filter(Boolean);
const map = {};
for (const line of lines) {
const i = line.indexOf('=');
if (i < 0) continue;
map[line.slice(0, i)] = line.slice(i + 1);
}
const prev = map[key] || null;
map[key] = value;
const merged = Object.keys(map).map(k => k + '=' + map[k]).join('\\n') + '\\n';
const tries = [];
const payloads = [
{ path: '/api/application.update', body: { applicationId: appId, env: merged } },
{ path: '/api/trpc/application.update?batch=1', body: { '0': { json: { applicationId: appId, env: merged } } } },
{ path: '/api/trpc/application.saveEnvironment?batch=1', body: { '0': { json: { applicationId: appId, env: merged } } } },
];
for (const t of payloads) {
const r = await fetch(t.path, {
method: 'POST',
credentials: 'include',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(t.body),
});
const text = await r.text();
tries.push({ path: t.path, status: r.status, body: text.slice(0, 160) });
if (r.ok) {
return { ok: true, prev, set: value, keys: Object.keys(map).length, tries };
}
}
return { ok: false, step: 'update', prev, tries };
}""",
{"appId": APP_ID, "key": KEY, "value": VALUE},
)
browser.close()
print(result, flush=True)
return 0 if result.get("ok") else 1
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,177 @@
#!/usr/bin/env python3
"""Merge SUPERHUMAN_DOCS_API_KEY into client-portal Dokploy env, then Deploy."""
from __future__ import annotations
import json
import re
import sys
import time
from pathlib import Path
from playwright.sync_api import sync_playwright
ROOT = Path("/Users/4ventures/4ventures-client-portal")
BPS = Path("/Users/4ventures/Projects/bps-facturacion")
BASE = "https://dokploy.4ventures.es"
APP_ID = "NZS797t9Rnq9Pj4mHiii3"
APP = f"{BASE}/dashboard/project/Z_4M-uDOH1_--itGSTh4l/environment/bGja70l_a7HQ1dxyXqSuf/services/application/{APP_ID}"
HEALTH = "https://app.4ventures.es/api/health"
def cred(key: str) -> str:
import os
v = os.environ.get(key, "").strip()
if v:
return v
p = BPS / ".env"
if not p.is_file():
return ""
for line in p.read_text(encoding="utf-8").splitlines():
if line.startswith(f"{key}="):
return line.split("=", 1)[1].strip().strip('"').strip("'")
return ""
def local_key() -> str:
for line in (ROOT / ".env.local").read_text(encoding="utf-8").splitlines():
if line.startswith("SUPERHUMAN_DOCS_API_KEY="):
return line.split("=", 1)[1].strip().strip('"').strip("'")
p = ROOT / ".ops" / "superhuman-docs-api-key.txt"
if p.is_file():
return p.read_text(encoding="utf-8").strip()
return ""
def parse_env(text: str) -> dict[str, str]:
out: dict[str, str] = {}
for line in (text or "").splitlines():
if not line.strip() or line.strip().startswith("#") or "=" not in line:
continue
k, v = line.split("=", 1)
out[k.strip()] = v.strip()
return out
def dump_env(vals: dict[str, str]) -> str:
return "\n".join(f"{k}={v}" for k, v in vals.items()) + "\n"
def main() -> int:
sh_key = local_key()
if not sh_key:
print("missing SUPERHUMAN_DOCS_API_KEY locally", flush=True)
return 2
email = cred("DOKPLOY_EMAIL") or "marketing@4ventures.es"
password = cred("DOKPLOY_PASSWORD")
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page(viewport={"width": 1400, "height": 1000})
page.goto(BASE + "/", wait_until="domcontentloaded", timeout=90000)
page.wait_for_timeout(600)
if "/dashboard" not in page.url:
page.locator('input[type="email"], input[name="email"]').first.fill(email)
page.locator('input[type="password"]').first.fill(password)
page.locator('button[type="submit"]').first.click()
page.wait_for_url(re.compile(r"/dashboard"), timeout=90000)
print("login ok", flush=True)
page.goto(APP, wait_until="networkidle", timeout=120000)
page.wait_for_timeout(800)
result = page.evaluate(
"""async ({ appId, shKey }) => {
const oneUrl = '/api/trpc/application.one?batch=1&input=' + encodeURIComponent(JSON.stringify({ '0': { json: { applicationId: appId } } }));
const one = await fetch(oneUrl, { credentials: 'include' });
const oneText = await one.text();
if (!one.ok) return { ok: false, step: 'one', status: one.status, body: oneText.slice(0, 400) };
let envText = '';
try {
const j = JSON.parse(oneText);
const data = j[0]?.result?.data?.json || j[0]?.result?.data || j;
envText = data?.env || data?.environment || '';
if (typeof envText !== 'string') envText = '';
} catch (e) {
return { ok: false, step: 'parse', error: String(e), body: oneText.slice(0, 400) };
}
const lines = envText.split('\\n').filter(Boolean);
const map = {};
for (const line of lines) {
const i = line.indexOf('=');
if (i < 0) continue;
map[line.slice(0, i)] = line.slice(i + 1);
}
map['SUPERHUMAN_DOCS_API_KEY'] = shKey;
const merged = Object.keys(map).map(k => k + '=' + map[k]).join('\\n') + '\\n';
const keys = Object.keys(map);
const tries = [];
const payloads = [
{ path: '/api/application.update', body: { applicationId: appId, env: merged } },
{ path: '/api/trpc/application.update?batch=1', body: { '0': { json: { applicationId: appId, env: merged } } } },
{ path: '/api/trpc/application.saveEnvironment?batch=1', body: { '0': { json: { applicationId: appId, env: merged } } } },
];
for (const t of payloads) {
const r = await fetch(t.path, {
method: 'POST',
credentials: 'include',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(t.body),
});
const text = await r.text();
tries.push({ path: t.path, status: r.status, body: text.slice(0, 180) });
if (r.ok) return { ok: true, keys, tries, hadKey: keys.includes('SUPERHUMAN_DOCS_API_KEY') };
}
return { ok: false, keys, tries, envLen: envText.length };
}""",
{"appId": APP_ID, "shKey": sh_key},
)
print(
json.dumps(
{
"ok": result.get("ok"),
"hadKey": result.get("hadKey"),
"keyCount": len(result.get("keys") or []),
"tries": result.get("tries"),
"step": result.get("step"),
"status": result.get("status"),
},
indent=2,
)[:2000],
flush=True,
)
if not result.get("ok"):
browser.close()
return 6
# Deploy
page.goto(APP, wait_until="domcontentloaded", timeout=90000)
page.wait_for_timeout(700)
page.get_by_role("tab", name=re.compile(r"^General$", re.I)).first.click()
page.wait_for_timeout(500)
page.get_by_role("button", name=re.compile(r"^Deploy$", re.I)).first.click()
page.wait_for_timeout(600)
conf = page.get_by_role("button", name=re.compile(r"^Confirm$", re.I))
if conf.count():
conf.first.click()
print("DEPLOY_CLICKED", flush=True)
for i in range(24):
time.sleep(10)
try:
import urllib.request
code = urllib.request.urlopen(HEALTH, timeout=12).getcode()
except Exception as e:
code = str(e)
print(f"health_try{i}", code, flush=True)
if code == 200:
break
browser.close()
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,137 @@
#!/usr/bin/env python3
"""Verify agent_runs RLS; apply sql/agent_runs_rls.sql only if still disabled."""
from __future__ import annotations
import base64
import re
import sys
from pathlib import Path
from playwright.sync_api import sync_playwright
ROOT = Path("/Users/4ventures/4ventures-client-portal")
BPS = Path("/Users/4ventures/Projects/bps-facturacion")
SHOT = ROOT / "scripts/dokploy-shots"
OUT = ROOT / ".ops" / "rls_inventory.txt"
BASE = "https://dokploy.4ventures.es"
SQL_FILE = ROOT / "sql/agent_runs_rls.sql"
INVENTORY = (
"SELECT c.relname AS table_name, c.relrowsecurity AS rls_enabled "
"FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace "
"WHERE n.nspname = 'public' AND c.relkind = 'r' ORDER BY 1;"
)
def env(key: str) -> str:
import os
v = os.environ.get(key, "").strip()
if v:
return v
p = BPS / ".env"
if not p.is_file():
return ""
for line in p.read_text(encoding="utf-8").splitlines():
if line.startswith(f"{key}="):
return line.split("=", 1)[1].strip().strip('"').strip("'")
return ""
def term_run(page, cmd: str, wait_ms: int = 4000) -> None:
helper = page.locator("textarea.xterm-helper-textarea")
if helper.count():
helper.first.click()
page.keyboard.type(cmd, delay=2)
page.keyboard.press("Enter")
page.wait_for_timeout(wait_ms)
def term_text(page) -> str:
return page.evaluate(
"""() => {
const rows = document.querySelector('.xterm-rows');
return rows ? rows.innerText : '';
}"""
)
def main() -> int:
email = env("DOKPLOY_EMAIL") or "marketing@4ventures.es"
password = env("DOKPLOY_PASSWORD")
if not password:
print("NO_DOKPLOY_PASSWORD", flush=True)
return 2
SHOT.mkdir(parents=True, exist_ok=True)
OUT.parent.mkdir(parents=True, exist_ok=True)
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page(viewport={"width": 1600, "height": 1200})
page.goto(BASE + "/", wait_until="domcontentloaded", timeout=90000)
page.wait_for_timeout(700)
if "/dashboard" not in page.url:
page.locator('input[type="email"], input[name="email"]').first.fill(email)
page.locator('input[type="password"]').first.fill(password)
page.locator('button[type="submit"]').first.click()
page.wait_for_url(re.compile(r"/dashboard"), timeout=90000)
print("login ok", flush=True)
page.goto(f"{BASE}/dashboard/docker", wait_until="networkidle", timeout=120000)
page.wait_for_timeout(1000)
page.locator("input[placeholder*='ilter' i], input[placeholder*='earch' i]").first.fill(
"supabase-db"
)
page.wait_for_timeout(1200)
page.keyboard.press("Escape")
page.wait_for_timeout(300)
page.get_by_role("button", name=re.compile(r"Open menu", re.I)).first.click()
page.wait_for_timeout(600)
page.get_by_text(re.compile(r"^Terminal$", re.I)).first.click()
page.wait_for_timeout(3500)
term_run(page, f'psql -U postgres -d postgres -c "{INVENTORY}" ; echo RLS_INV', 7000)
text = term_text(page)
OUT.write_text(text[-4000:], encoding="utf-8")
print("inventory_written", flush=True)
agent_on = bool(re.search(r"agent_runs\s+\|\s+t\b", text))
agent_off = bool(re.search(r"agent_runs\s+\|\s+f\b", text))
print("agent_runs_rls", "on" if agent_on else ("off" if agent_off else "unknown"), flush=True)
if agent_on:
print("OK_ALREADY", flush=True)
browser.close()
return 0
if not agent_off:
print("PARSE_FAIL — see .ops/rls_inventory.txt", flush=True)
page.screenshot(path=str(SHOT / "rls_parse_fail.png"), full_page=True)
browser.close()
return 4
# Apply SQL via base64 chunks
b64 = base64.b64encode(SQL_FILE.read_text(encoding="utf-8").encode()).decode()
chunks = [b64[i : i + 200] for i in range(0, len(b64), 200)]
term_run(page, "rm -f /tmp/agent_rls.b64 /tmp/agent_rls.sql; echo READY", 2000)
for i, ch in enumerate(chunks):
op = ">" if i == 0 else ">>"
term_run(page, f"printf '%s' '{ch}' {op} /tmp/agent_rls.b64", 800)
term_run(
page,
"base64 -d /tmp/agent_rls.b64 > /tmp/agent_rls.sql && "
"psql -U postgres -d postgres -v ON_ERROR_STOP=1 -f /tmp/agent_rls.sql; "
"echo EXIT:$?",
8000,
)
term_run(page, f'psql -U postgres -d postgres -c "{INVENTORY}" ; echo RLS_INV2', 7000)
text2 = term_text(page)
OUT.write_text(text2[-4000:], encoding="utf-8")
page.screenshot(path=str(SHOT / "rls_after.png"), full_page=True)
ok = bool(re.search(r"agent_runs\s+\|\s+t\b", text2))
print("agent_runs_after", "on" if ok else "still_off", flush=True)
browser.close()
return 0 if ok else 6
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,63 @@
import { config } from 'dotenv';
import { resolve } from 'path';
import { writeFileSync, mkdirSync } from 'fs';
config({ path: resolve(process.cwd(), '.env.local') });
import { getValidAccessToken } from '../src/lib/google-drive';
const IDS = [
'1YPcHBjioiHHQy22Q2xevkvUqA5jMq87v6IviV7VTL2s',
'1-Zss1suBLDxAgqUuP9DrJYWHiicyin8aQqMOTIIekAg',
];
async function main() {
const token = await getValidAccessToken();
mkdirSync('/tmp/bps-cal', { recursive: true });
for (const id of IDS) {
const metaR = await fetch(
`https://www.googleapis.com/drive/v3/files/${id}?fields=id,name,mimeType&supportsAllDrives=true`,
{ headers: { Authorization: `Bearer ${token}` } }
);
const meta = (await metaR.json()) as {
id: string;
name: string;
mimeType: string;
};
console.log('META', meta);
const mime = meta.mimeType || '';
let buf: Buffer;
if (mime.includes('spreadsheet')) {
const r = await fetch(
`https://www.googleapis.com/drive/v3/files/${id}/export?mimeType=${encodeURIComponent('text/csv')}`,
{ headers: { Authorization: `Bearer ${token}` } }
);
if (!r.ok) throw new Error(await r.text());
buf = Buffer.from(await r.arrayBuffer());
writeFileSync(`/tmp/bps-cal/${id}.csv`, buf);
} else if (mime.includes('document')) {
const r = await fetch(
`https://www.googleapis.com/drive/v3/files/${id}/export?mimeType=${encodeURIComponent('text/plain')}`,
{ headers: { Authorization: `Bearer ${token}` } }
);
if (!r.ok) throw new Error(await r.text());
buf = Buffer.from(await r.arrayBuffer());
writeFileSync(`/tmp/bps-cal/${id}.txt`, buf);
} else {
const r = await fetch(
`https://www.googleapis.com/drive/v3/files/${id}?alt=media&supportsAllDrives=true`,
{ headers: { Authorization: `Bearer ${token}` } }
);
if (!r.ok) throw new Error(await r.text());
buf = Buffer.from(await r.arrayBuffer());
writeFileSync(`/tmp/bps-cal/${id}.bin`, buf);
}
const text = buf.toString('utf8');
console.log('size', buf.length);
console.log(text.slice(0, 3000));
console.log('----\n');
}
}
main().catch((e) => {
console.error(e);
process.exit(1);
});

View File

@@ -0,0 +1,33 @@
import { config } from 'dotenv';
import { resolve } from 'path';
import { writeFileSync, mkdirSync } from 'fs';
config({ path: resolve(process.cwd(), '.env.local') });
import { getValidAccessToken } from '../src/lib/google-drive';
const ID = '1YPcHBjioiHHQy22Q2xevkvUqA5jMq87v6IviV7VTL2s';
const OCT = '1-Zss1suBLDxAgqUuP9DrJYWHiicyin8aQqMOTIIekAg';
async function exportXlsx(token: string, id: string, path: string) {
const mime =
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
const r = await fetch(
`https://www.googleapis.com/drive/v3/files/${id}/export?mimeType=${encodeURIComponent(mime)}`,
{ headers: { Authorization: `Bearer ${token}` } }
);
if (!r.ok) throw new Error(`${id} ${r.status} ${(await r.text()).slice(0, 300)}`);
const buf = Buffer.from(await r.arrayBuffer());
writeFileSync(path, buf);
console.log('wrote', path, buf.length);
}
async function main() {
const token = await getValidAccessToken();
mkdirSync('/tmp/bps-cal', { recursive: true });
await exportXlsx(token, ID, '/tmp/bps-cal/bps-calendario.xlsx');
await exportXlsx(token, OCT, '/tmp/bps-cal/bps-octubre.xlsx');
}
main().catch((e) => {
console.error(e);
process.exit(1);
});

View File

@@ -0,0 +1,450 @@
#!/usr/bin/env python3
"""Exact gap report: visible assets vs tenant matches + name candidates."""
from __future__ import annotations
import json
import re
import urllib.error
import urllib.parse
import urllib.request
from collections import defaultdict
from datetime import datetime, timezone, timedelta
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
META = "https://graph.facebook.com/v21.0"
def load_env() -> dict[str, str]:
vals: dict[str, str] = {}
for line in (ROOT / ".env.local").read_text(encoding="utf-8").splitlines():
if not line.strip() or line.strip().startswith("#") or "=" not in line:
continue
k, v = line.split("=", 1)
vals[k.strip()] = v.strip().strip('"').strip("'")
return vals
ENV = load_env()
BASE = ENV["NEXT_PUBLIC_SUPABASE_URL"].rstrip("/")
KEY = ENV["SUPABASE_SERVICE_ROLE_KEY"]
SH = {"apikey": KEY, "Authorization": f"Bearer {KEY}"}
def sb(path: str):
req = urllib.request.Request(BASE + path, headers=SH)
with urllib.request.urlopen(req, timeout=90) as r:
return json.loads(r.read().decode())
def norm(s: str) -> str:
s = s.lower()
for a, b in (
("á", "a"),
("é", "e"),
("í", "i"),
("ó", "o"),
("ú", "u"),
("ñ", "n"),
):
s = s.replace(a, b)
return re.sub(r"[^a-z0-9]+", " ", s).strip()
def score_name(tenant_name: str, tenant_slug: str, account_name: str) -> int:
t = norm(tenant_name)
s = norm(tenant_slug.replace("-", " "))
a = norm(account_name)
if not a:
return 0
if a == t or a == s:
return 100
if t and (t in a or a in t):
return 85
if s and (s in a or a in s):
return 80
tt = {x for x in t.split() if len(x) > 2}
aa = {x for x in a.split() if len(x) > 2}
if not tt or not aa:
return 0
hit = len(tt & aa) / len(tt)
if hit >= 0.8:
return 70
if hit >= 0.5:
return 55
return int(hit * 50)
def top_candidates(tenants, account_name, n=3):
scored = []
for t in tenants:
sc = score_name(t["name"], t["slug"], account_name)
if sc > 0:
scored.append((sc, t["name"]))
scored.sort(key=lambda x: -x[0])
return scored[:n]
def meta_paginate(token: str, path: str):
out = []
url = f"{META}/{path}"
sep = "&" if "?" in path else "?"
url = url + sep + "access_token=" + urllib.parse.quote(token)
while url:
req = urllib.request.Request(url)
with urllib.request.urlopen(req, timeout=90) as r:
j = json.loads(r.read().decode())
out.extend(j.get("data") or [])
url = (j.get("paging") or {}).get("next")
return out
def google_refresh(refresh: str) -> str:
body = urllib.parse.urlencode(
{
"refresh_token": refresh,
"client_id": ENV["GOOGLE_OAUTH_CLIENT_ID"],
"client_secret": ENV["GOOGLE_OAUTH_CLIENT_SECRET"],
"grant_type": "refresh_token",
}
).encode()
req = urllib.request.Request(
"https://oauth2.googleapis.com/token",
data=body,
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
with urllib.request.urlopen(req, timeout=60) as r:
return json.loads(r.read().decode())["access_token"]
def main() -> int:
tenants = sb("/rest/v1/tenants?select=id,name,slug&order=name.asc")
integs = sb(
"/rest/v1/integrations?provider=in.(ga4,search_console,google_drive,google_ads,meta_ads,facebook,instagram)"
"&select=tenant_id,provider,status,external_account_id,meta,error_message"
)
by = defaultdict(dict)
for i in integs:
by[i["tenant_id"]][i["provider"]] = i
grow = sb(
"/rest/v1/agency_oauth?provider=eq.google_drive&select=access_token,refresh_token,account_email"
)[0]
mrow = sb(
"/rest/v1/agency_oauth?provider=eq.meta_ads&select=access_token,account_email,scopes"
)[0]
gtoken = google_refresh(grow["refresh_token"])
mtoken = mrow["access_token"]
print("=== TOKENS ===")
print("Google:", grow.get("account_email"))
print("Meta:", mrow.get("account_email"))
# Meta inventory
ads = []
for row in meta_paginate(
mtoken, "me/adaccounts?fields=id,account_id,name&limit=200"
):
aid = row.get("account_id") or row["id"].replace("act_", "")
ads.append({"id": f"act_{aid}", "name": row.get("name") or aid})
pages = []
igs = []
for page in meta_paginate(
mtoken,
"me/accounts?fields=id,name,instagram_business_account{id,username,name}&limit=100",
):
pages.append({"id": page["id"], "name": page.get("name") or page["id"]})
ig = page.get("instagram_business_account") or {}
if ig.get("id"):
igs.append(
{
"id": ig["id"],
"name": f"@{ig['username']}"
if ig.get("username")
else (ig.get("name") or page.get("name") or ig["id"]),
"page": page.get("name"),
}
)
print(f"\n=== META VISIBLE === ads={len(ads)} pages={len(pages)} ig={len(igs)}")
print("Ad accounts:")
for a in ads:
cands = top_candidates(tenants, a["name"])
linked = next(
(
t["name"]
for t in tenants
if (by[t["id"]].get("meta_ads") or {}).get("external_account_id")
== a["id"]
),
None,
)
print(
f" {a['name']!r} ({a['id']}) → linked={linked or 'NO'} best={cands}"
)
print("Pages:")
for a in pages:
cands = top_candidates(tenants, a["name"])
linked = next(
(
t["name"]
for t in tenants
if (by[t["id"]].get("facebook") or {}).get("external_account_id")
== a["id"]
),
None,
)
print(
f" {a['name']!r} ({a['id']}) → linked={linked or 'NO'} best={cands}"
)
print("Instagram:")
for a in igs:
cands = top_candidates(tenants, a["name"]) + top_candidates(
tenants, a.get("page") or ""
)
# dedupe keep best
seen = {}
for sc, name in cands:
seen[name] = max(sc, seen.get(name, 0))
cands = sorted(((v, k) for k, v in seen.items()), reverse=True)[:3]
linked = next(
(
t["name"]
for t in tenants
if (by[t["id"]].get("instagram") or {}).get("external_account_id")
== a["id"]
),
None,
)
print(
f" {a['name']!r} page={a.get('page')!r} ({a['id']}) → linked={linked or 'NO'} best={cands}"
)
# GA4 / GSC inventory
req = urllib.request.Request(
"https://analyticsadmin.googleapis.com/v1beta/accountSummaries",
headers={"Authorization": f"Bearer {gtoken}"},
)
with urllib.request.urlopen(req, timeout=90) as r:
summaries = json.loads(r.read().decode())
ga4 = []
for acc in summaries.get("accountSummaries") or []:
for p in acc.get("propertySummaries") or []:
if p.get("property"):
ga4.append(
{
"id": p["property"],
"name": p.get("displayName") or p["property"],
"account": acc.get("displayName"),
}
)
req = urllib.request.Request(
"https://www.googleapis.com/webmasters/v3/sites",
headers={"Authorization": f"Bearer {gtoken}"},
)
with urllib.request.urlopen(req, timeout=90) as r:
sites_j = json.loads(r.read().decode())
gsc = [
{"id": s["siteUrl"], "name": s["siteUrl"]}
for s in (sites_j.get("siteEntry") or [])
if s.get("siteUrl")
]
print(f"\n=== GOOGLE VISIBLE === ga4={len(ga4)} gsc={len(gsc)}")
# Test Data API on first property
if ga4:
prop = ga4[0]["id"].replace("properties/", "")
end = (datetime.now(timezone.utc) - timedelta(days=1)).date()
start = end - timedelta(days=2)
body = json.dumps(
{
"dateRanges": [{"startDate": str(start), "endDate": str(end)}],
"dimensions": [{"name": "date"}],
"metrics": [{"name": "sessions"}],
}
).encode()
req = urllib.request.Request(
f"https://analyticsdata.googleapis.com/v1beta/properties/{prop}:runReport",
data=body,
headers={
"Authorization": f"Bearer {gtoken}",
"Content-Type": "application/json",
},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=60) as r:
print("Data API test: OK on", ga4[0]["name"], prop)
except urllib.error.HTTPError as e:
print("Data API test: FAIL", e.read().decode()[:250])
# Unmatched GA4 with best candidates (only those not linked)
linked_ga4_ids = {
(by[t["id"]].get("ga4") or {}).get("external_account_id")
for t in tenants
}
linked_ga4_props = set()
for t in tenants:
meta = (by[t["id"]].get("ga4") or {}).get("meta") or {}
if meta.get("property_id"):
linked_ga4_props.add(meta["property_id"])
print("\n=== GA4 sin ficha clara (score<50 o no enlazado) — top candidatos ===")
for a in ga4:
linked = next(
(
t["name"]
for t in tenants
if (by[t["id"]].get("ga4") or {}).get("meta", {}).get("property_id")
== a["id"]
or (by[t["id"]].get("ga4") or {}).get("external_account_id")
in (a["id"], a["name"])
),
None,
)
cands = top_candidates(tenants, a["name"])
best = cands[0][0] if cands else 0
if linked and best >= 50:
continue
if linked:
continue
print(f" GA4 {a['name']!r} ({a['id']}) acct={a.get('account')!r} best={cands}")
print("\n=== GSC sin ficha clara ===")
for a in gsc:
linked = next(
(
t["name"]
for t in tenants
if (by[t["id"]].get("search_console") or {}).get("external_account_id")
== a["id"]
),
None,
)
if linked:
continue
cands = top_candidates(tenants, a["name"])
# also try domain without sc-domain
bare = a["name"].replace("sc-domain:", "").replace("https://", "").replace("http://", "").strip("/")
cands2 = top_candidates(tenants, bare)
merged = {}
for sc, name in cands + cands2:
merged[name] = max(sc, merged.get(name, 0))
cands = sorted(((v, k) for k, v in merged.items()), reverse=True)[:3]
print(f" GSC {a['name']!r} best={cands}")
# Per-tenant gaps with why
print("\n=== POR CLIENTE: qué falta y por qué ===")
providers = [
"google_ads",
"meta_ads",
"ga4",
"search_console",
"google_drive",
"facebook",
"instagram",
]
def ok(i):
return bool(i and i.get("status") == "connected" and i.get("external_account_id"))
for t in tenants:
m = by[t["id"]]
gaps = []
for p in providers:
if ok(m.get(p)):
continue
if p == "meta_ads":
cands = [(score_name(t["name"], t["slug"], a["name"]), a["name"], a["id"]) for a in ads]
cands = [c for c in sorted(cands, reverse=True) if c[0] > 0][:2]
if not ads:
gaps.append("meta_ads: token no ve ad accounts")
elif not cands:
gaps.append("meta_ads: no hay ad account cuyo nombre se parezca (¿no está en las 9 visibles?)")
else:
gaps.append(f"meta_ads: no auto-match; candidatos {cands}")
elif p == "facebook":
cands = [(score_name(t["name"], t["slug"], a["name"]), a["name"], a["id"]) for a in pages]
cands = [c for c in sorted(cands, reverse=True) if c[0] > 0][:2]
if not cands:
gaps.append("facebook: ninguna Page visible se parece al nombre")
else:
gaps.append(f"facebook: no auto-match; candidatos {cands}")
elif p == "instagram":
cands = []
for a in igs:
sc = max(
score_name(t["name"], t["slug"], a["name"]),
score_name(t["name"], t["slug"], a.get("page") or ""),
)
if sc > 0:
cands.append((sc, a["name"], a.get("page"), a["id"]))
cands = sorted(cands, reverse=True)[:2]
if not cands:
gaps.append("instagram: ningún @ / page visible se parece")
else:
gaps.append(f"instagram: no auto-match; candidatos {cands}")
elif p == "ga4":
cands = [(score_name(t["name"], t["slug"], a["name"]), a["name"], a["id"]) for a in ga4]
cands = [c for c in sorted(cands, reverse=True) if c[0] > 0][:2]
if not cands:
gaps.append("ga4: ninguna propiedad visible se parece al nombre")
else:
gaps.append(f"ga4: no auto-match (umbral 50); candidatos {cands}")
elif p == "search_console":
cands = []
for a in gsc:
bare = a["name"].replace("sc-domain:", "").replace("https://", "").replace("http://", "").strip("/")
sc = max(
score_name(t["name"], t["slug"], a["name"]),
score_name(t["name"], t["slug"], bare),
)
if sc > 0:
cands.append((sc, a["name"]))
cands = sorted(cands, reverse=True)[:2]
if not cands:
gaps.append("gsc: ningún site visible se parece")
else:
gaps.append(f"gsc: no auto-match (umbral 40); candidatos {cands}")
elif p == "google_drive":
gaps.append("drive: sin carpeta auto-matched (revisar nombre carpeta Drive)")
elif p == "google_ads":
gaps.append("google_ads: sin cuenta enlazada (MCC / nombre)")
if gaps:
print(f"\n{t['name']}:")
for g in gaps:
print(f" - {g}")
else:
print(f"\n{t['name']}: COMPLETO (todos los canales con ID)")
# BPS GSC permission probe
bps = next(t for t in tenants if t["name"] == "BPS")
gsc_id = (by[bps["id"]].get("search_console") or {}).get("external_account_id")
if gsc_id:
end = (datetime.now(timezone.utc) - timedelta(days=1)).date()
start = end - timedelta(days=3)
body = json.dumps(
{"startDate": str(start), "endDate": str(end), "dimensions": ["date"]}
).encode()
req = urllib.request.Request(
f"https://www.googleapis.com/webmasters/v3/sites/{urllib.parse.quote(gsc_id, safe='')}/searchAnalytics/query",
data=body,
headers={
"Authorization": f"Bearer {gtoken}",
"Content-Type": "application/json",
},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=60) as r:
print("\nBPS GSC probe: OK")
except urllib.error.HTTPError as e:
print("\nBPS GSC probe: FAIL", e.read().decode()[:220])
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,457 @@
/**
* Import BPS editorial calendars from Drive xlsx into documents (type=calendar).
* Includes hidden month tabs (JULIO…JUNIO, ENERO 2026, *26).
*
* Prerequisite: /tmp/bps-cal/bps-calendario.xlsx (+ optional bps-octubre.xlsx)
* npx tsx scripts/export_bps_xlsx.ts
* npx tsx scripts/import_bps_calendars.ts
*/
import { config } from 'dotenv';
import { resolve } from 'path';
import { writeFileSync } from 'fs';
import { execFileSync } from 'child_process';
config({ path: resolve(process.cwd(), '.env.local') });
import { serviceHeaders, supabaseUrl } from '../src/lib/agency';
import type { RrssMonthPlan, RrssPiece } from '../src/components/RrssMonthView';
const BPS_ID = '8bec977a-6032-4759-aa7f-b5042081f450';
/**
* Explicit year/month for every editorial month sheet (incl. hidden).
* Workbook order: JUL–DIC 2025 → ENE–JUN 2026 → JUL–OCT 26.
*/
const SHEET_YM: Record<string, string> = {
JULIO: '2025-07',
AGOSTO: '2025-08',
SEPTIEMBRE: '2025-09',
OCTUBRE: '2025-10',
NOVIEMBRE: '2025-11',
DICIEMBRE: '2025-12',
'ENERO 2026': '2026-01',
FEBRERO: '2026-02',
MARZO: '2026-03',
ABRIL: '2026-04',
MAYO: '2026-05',
JUNIO: '2026-06',
'JULIO 26': '2026-07',
'AGOSTO 26': '2026-08',
'SEPTIMEBRE 26': '2026-09',
'OCTUBRE 26': '2026-10',
};
const MONTH_SHEET_NAMES = new Set(Object.keys(SHEET_YM));
function slugify(s: string) {
return s
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
.slice(0, 48);
}
function parseDayHeader(h: string): number | null {
const raw = String(h || '').trim();
if (!raw || /^x\b/i.test(raw) || /^columna/i.test(raw)) return null;
const m = raw.match(/(\d{1,2})\s*$/);
if (!m) return null;
const n = Number(m[1]);
return n >= 1 && n <= 31 ? n : null;
}
type SheetRows = (string | number | null)[][];
/** Parse SEMANA/STORIES/POST grid into pieces. */
function parseWeekGrid(
sheetName: string,
rows: SheetRows,
year: number,
month: number
): RrssPiece[] {
const pieces: RrssPiece[] = [];
let dayCols: { col: number; day: number }[] = [];
let kind: 'STORIES' | 'POST' | null = null;
/** Week wraps when header has e.g. LUNES 30 … MARTES 1 (spillover from prev month). */
let weekWraps = false;
for (const row of rows) {
if (!row || !row.some((c) => c != null && String(c).trim())) continue;
const c0 = String(row[0] ?? '')
.trim()
.toUpperCase();
if (
c0.startsWith('SEMANA') ||
/^(LUNES|MARTES|MI[EÉ]RCOLES)/i.test(String(row[1] ?? ''))
) {
dayCols = [];
for (let col = 1; col < Math.min(row.length, 10); col++) {
const day = parseDayHeader(String(row[col] ?? ''));
if (day) dayCols.push({ col, day });
}
weekWraps = dayCols.some(
(d, i) =>
i > 0 &&
d.day < dayCols[i - 1].day &&
dayCols[i - 1].day >= 28 &&
d.day <= 7
);
kind = null;
continue;
}
if (c0.startsWith('STORIES') || c0 === 'STORY') {
kind = 'STORIES';
} else if (c0.startsWith('POST')) {
kind = 'POST';
} else if (!kind) {
continue;
}
for (const { col, day } of dayCols) {
const raw = row[col];
if (raw == null) continue;
const text = String(raw).trim();
if (!text || text === 'X' || /^columna/i.test(text)) continue;
let y = year;
let m = month;
if (weekWraps && day >= 28) {
m = month - 1;
if (m < 1) {
m = 12;
y = year - 1;
}
}
const mm = String(m).padStart(2, '0');
const dd = String(day).padStart(2, '0');
const date = `${y}-${mm}-${dd}`;
const dt = new Date(`${date}T12:00:00Z`);
if (Number.isNaN(dt.getTime()) || dt.getUTCMonth() + 1 !== m) continue;
const id = `bps-${date}-${kind === 'STORIES' ? 'st' : 'post'}-${slugify(text).slice(0, 24)}`;
pieces.push({
id,
date,
platform: 'Instagram',
format: kind === 'STORIES' ? 'Stories' : 'Post',
pillar: kind === 'STORIES' ? 'stories' : 'feed',
title: text.split('\n')[0].slice(0, 120),
body: text,
status: 'planned',
kind: kind === 'STORIES' ? 'story' : 'post',
color: kind === 'STORIES' ? '#D9821F' : '#2071cf',
});
}
}
const seen = new Set<string>();
return pieces.filter((p) => {
if (seen.has(p.id)) return false;
seen.add(p.id);
return true;
});
}
async function loadSheetsFromPython(): Promise<
{ name: string; rows: SheetRows; state: string }[]
> {
const py = `
import openpyxl, json
wb=openpyxl.load_workbook('/tmp/bps-cal/bps-calendario.xlsx', data_only=True)
out=[]
month_names={${[...MONTH_SHEET_NAMES].map((n) => JSON.stringify(n)).join(',')}}
for name in wb.sheetnames:
ws=wb[name]
keep = name in month_names or name == 'Calendario'
if not keep:
continue
rows=[]
# Read enough rows; openpyxl max_row is often inflated (1000) — stop after 80 blank streak
blank=0
for i,row in enumerate(ws.iter_rows(min_row=1, max_row=min(ws.max_row, 200), values_only=True),1):
vals=[None if c is None else (c.isoformat() if hasattr(c,'isoformat') else c) for c in row[:14]]
if not any(v is not None and str(v).strip() for v in vals):
blank+=1
if blank>15 and i>30: break
continue
blank=0
rows.append(vals)
out.append({'name': name, 'rows': rows, 'state': ws.sheet_state})
try:
wb2=openpyxl.load_workbook('/tmp/bps-cal/bps-octubre.xlsx', data_only=True)
for name in wb2.sheetnames:
ws=wb2[name]
rows=[]
blank=0
for i,row in enumerate(ws.iter_rows(min_row=1, max_row=min(ws.max_row, 80), values_only=True),1):
vals=[None if c is None else (c.isoformat() if hasattr(c,'isoformat') else c) for c in row[:14]]
if not any(v is not None and str(v).strip() for v in vals):
blank+=1
if blank>10: break
continue
blank=0
rows.append(vals)
out.append({'name': name+' (prov)', 'rows': rows, 'state': 'visible'})
except Exception:
pass
print(json.dumps(out, ensure_ascii=False, default=str))
`;
const raw = execFileSync('python3', ['-c', py], { maxBuffer: 20_000_000 });
return JSON.parse(raw.toString('utf8'));
}
function parseStructuredCalendario(rows: SheetRows): Map<string, RrssPiece[]> {
const byMonth = new Map<string, RrssPiece[]>();
for (const r of rows.slice(1)) {
if (!r) continue;
const dia = r[2];
const formato = String(r[3] ?? '');
const contenido = String(r[4] ?? '').trim();
const estado = String(r[6] ?? '');
if (!contenido && !formato) continue;
let dateStr = '';
if (typeof dia === 'string' && /^\d{4}-\d{2}-\d{2}/.test(dia)) {
dateStr = dia.slice(0, 10);
} else {
continue;
}
const monthKey = dateStr.slice(0, 7);
const isStory = /storie/i.test(formato);
const isBlog = /blog/i.test(formato);
const piece: RrssPiece = {
id: `bps-${dateStr}-${isStory ? 'st' : isBlog ? 'blog' : 'post'}-${slugify(contenido || formato)}`,
date: dateStr,
platform: isBlog ? 'Blog' : 'Instagram',
format: isStory ? 'Stories' : isBlog ? 'Blog' : 'Post',
pillar: isStory ? 'stories' : 'feed',
title: (contenido || formato).split('\n')[0].slice(0, 120) || formato,
body: contenido || undefined,
status: /subido/i.test(estado)
? 'published'
: /pendiente/i.test(estado)
? 'planned'
: 'planned',
kind: isStory ? 'story' : 'post',
color: isStory ? '#D9821F' : '#2071cf',
};
const list = byMonth.get(monthKey) || [];
list.push(piece);
byMonth.set(monthKey, list);
}
return byMonth;
}
function monthLabel(ym: string) {
const [y, m] = ym.split('-').map(Number);
const names = [
'',
'Enero',
'Febrero',
'Marzo',
'Abril',
'Mayo',
'Junio',
'Julio',
'Agosto',
'Septiembre',
'Octubre',
'Noviembre',
'Diciembre',
];
return `${names[m]} ${y}`;
}
function buildPlan(ym: string, pieces: RrssPiece[]): RrssMonthPlan {
const posts = pieces.filter((p) => p.kind !== 'story').length;
const stories = pieces.filter((p) => p.kind === 'story').length;
return {
kind: 'rrss_month',
month: ym,
label: monthLabel(ym),
hero: {
eyebrow: '4VENTURES × BLUE PARROT SCHOOL · PLAN RRSS',
title: `Calendario editorial · ${monthLabel(ym)}`,
lede: 'Importado desde Drive (CALENDARIO Blue Parrot School), incluidas pestañas ocultas.',
chips: [
`${posts} posts`,
`${stories} stories`,
'Fuente: Google Drive',
],
},
pieces: pieces.sort((a, b) => a.date.localeCompare(b.date)),
};
}
async function upsertPlan(plan: RrssMonthPlan) {
const headers = serviceHeaders();
if (!headers) throw new Error('no service role');
const base = supabaseUrl();
const title = `RRSS ${plan.label}`;
const existing = await fetch(
`${base}/rest/v1/documents?tenant_id=eq.${BPS_ID}&type=eq.calendar&select=id,title,content`,
{ headers }
);
const docs = (await existing.json()) as {
id: string;
title: string;
content: unknown;
}[];
for (const d of docs) {
let c = d.content as RrssMonthPlan | string | null;
if (typeof c === 'string') {
try {
c = JSON.parse(c) as RrssMonthPlan;
} catch {
c = null;
}
}
const month = c && typeof c === 'object' ? c.month : null;
const sameTitle = d.title === title || d.title === `RRSS ${plan.label}`;
if (month === plan.month || sameTitle) {
await fetch(`${base}/rest/v1/documents?id=eq.${d.id}`, {
method: 'DELETE',
headers: { ...headers, Prefer: 'return=minimal' },
});
}
}
const row = {
tenant_id: BPS_ID,
type: 'calendar',
title,
content: plan,
status: 'approved',
};
const ins = await fetch(`${base}/rest/v1/documents`, {
method: 'POST',
headers: {
...headers,
'Content-Type': 'application/json',
Prefer: 'return=representation',
},
body: JSON.stringify(row),
});
if (!ins.ok) throw new Error(`insert ${plan.month}: ${(await ins.text()).slice(0, 300)}`);
const saved = (await ins.json()) as { id: string }[];
console.log('OK', plan.month, plan.pieces.length, 'pieces', saved[0]?.id);
}
function mergePieces(a: RrssPiece[], b: RrssPiece[]): RrssPiece[] {
const ids = new Set(a.map((p) => p.id));
return a.concat(b.filter((p) => !ids.has(p.id)));
}
async function main() {
const sheets = await loadSheetsFromPython();
const byMonth = new Map<string, RrssPiece[]>();
const gridMonths = new Set<string>();
// 1) All month tabs (hidden + visible) — primary source
for (const sh of sheets) {
const baseName = sh.name.replace(' (prov)', '');
const ym = SHEET_YM[baseName];
if (!ym) continue;
const [year, month] = ym.split('-').map(Number);
const pieces = parseWeekGrid(sh.name, sh.rows as SheetRows, year, month);
const cur = byMonth.get(ym) || [];
if (sh.name.includes('(prov)') && pieces.length >= cur.length) {
byMonth.set(ym, pieces);
} else {
byMonth.set(ym, mergePieces(cur, pieces));
}
if (pieces.length) gridMonths.add(ym);
console.log(
`parsed ${sh.name} [${sh.state}] → ${ym} ${pieces.length} pieces`
);
}
// 2) Structured "Calendario" sheet: only fill months that have NO week-grid tab
// (May–Aug 2025 detail) or merge blog rows into existing months
for (const sh of sheets) {
if (sh.name !== 'Calendario') continue;
const structured = parseStructuredCalendario(sh.rows as SheetRows);
for (const [ym, pieces] of structured) {
if (gridMonths.has(ym)) {
// keep grid as source of truth; still add blog-only pieces
const blogs = pieces.filter((p) => p.platform === 'Blog');
if (blogs.length) {
byMonth.set(ym, mergePieces(byMonth.get(ym) || [], blogs));
console.log(`calendario blogs → ${ym} +${blogs.length}`);
}
continue;
}
byMonth.set(ym, mergePieces(byMonth.get(ym) || [], pieces));
console.log(`calendario only → ${ym} ${pieces.length}`);
}
}
// Drop wrongly-year'd leftovers from prior import (e.g. Feb–Apr as 2025)
const keepMonths = new Set(byMonth.keys());
writeFileSync(
'/tmp/bps-cal/plans-summary.json',
JSON.stringify(
[...byMonth.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([m, p]) => ({ month: m, n: p.length })),
null,
2
)
);
const headers = serviceHeaders();
const base = supabaseUrl();
if (headers && base) {
const existing = await fetch(
`${base}/rest/v1/documents?tenant_id=eq.${BPS_ID}&type=eq.calendar&select=id,title,content`,
{ headers }
);
const docs = (await existing.json()) as {
id: string;
content: unknown;
}[];
for (const d of docs) {
let c = d.content as { month?: string } | string | null;
if (typeof c === 'string') {
try {
c = JSON.parse(c) as { month?: string };
} catch {
c = null;
}
}
const m = c && typeof c === 'object' ? c.month : null;
if (!m) continue;
// Remove old 2025-02..04 if we now have 2026-02..04 from hidden sheets
if (
/^2025-0[2-4]$/.test(m) &&
keepMonths.has(m.replace('2025', '2026'))
) {
await fetch(`${base}/rest/v1/documents?id=eq.${d.id}`, {
method: 'DELETE',
headers: { ...headers, Prefer: 'return=minimal' },
});
console.log('deleted stale', m, d.id);
}
}
}
for (const [ym, pieces] of [...byMonth.entries()].sort()) {
if (!pieces.length) continue;
await upsertPlan(buildPlan(ym, pieces));
}
console.log(
'months',
[...byMonth.keys()].sort().join(', '),
'total pieces',
[...byMonth.values()].reduce((s, p) => s + p.length, 0)
);
}
main().catch((e) => {
console.error(e);
process.exit(1);
});

95
scripts/ingest_gads_json.py Executable file
View File

@@ -0,0 +1,95 @@
#!/usr/bin/env python3
"""Ingest .ops/gads/{customer_id}.json MCP dumps into ad_daily."""
from __future__ import annotations
import json, os, sys, urllib.request, urllib.error
from pathlib import Path
from datetime import datetime, timezone, timedelta
ROOT = Path('/Users/4ventures/4ventures-client-portal')
# load .env.local
for line in (ROOT/'.env.local').read_text().splitlines():
if '=' in line and not line.startswith('#') and line.split('=',1)[0] not in os.environ:
k,v=line.split('=',1); os.environ[k]=v.strip().strip('"').strip("'")
url=os.environ['NEXT_PUBLIC_SUPABASE_URL'].rstrip('/')
key=os.environ['SUPABASE_SERVICE_ROLE_KEY']
h={'apikey':key,'Authorization':f'Bearer {key}','Content-Type':'application/json'}
MAP={
'3019377678':'a7a6eb49-435c-411e-bdb0-a45b6a75ffab',
'7658651782':'2f6587f1-5dab-4994-b99f-d62f4300dd92',
'4975185233':'25d3cdda-a3fc-4af7-b733-b38304b489df',
'3183140753':'506a6d00-dc5a-4d84-bedc-73a35e3014a6',
'8746480744':'98d023ba-1c5c-4cab-8ab9-4e70c4367bb1',
'7181044127':'6f5c19e4-bf8b-4ef0-8c7e-55c9d0c0a041',
'8436023380':'97def34e-e1c4-41b4-a579-036b14cd5091',
'2986275882':'229687b5-6861-4b6d-b321-e09197629731',
'6481978772':'8e5761b7-6347-4fd1-acbf-4572730a783a',
'9069365731':'73a1b415-2325-4cab-9769-823021a5e888',
'8177742252':'730feaf2-f2a3-4a18-963b-12b853551d8d',
'9627541456':'fd7d361b-c4d0-47ca-bc0e-75d21ac50da3',
'8592129580':'7df5293e-b954-4059-94da-d18534a08767',
'6871069003':'c538a248-4ef0-437b-b4a0-544cff597659',
'2508925045':'a5a250b4-e79a-4cb3-bfa1-4f9a6d908a03',
'4418211066':'48919ad2-6af0-4ef0-96a3-fd9ca0a46cd2',
'1323363601':'0b3c5fc0-6901-4caf-b26a-b6a0201d8541',
'1064441284':'8bec977a-6032-4759-aa7f-b5042081f450', # BPS / Blue Parrot School
}
def call(method, path, body=None):
headers=dict(h); headers['Prefer']='return=minimal'
data=None if body is None else json.dumps(body).encode()
req=urllib.request.Request(f'{url}{path}', data=data, headers=headers, method=method)
try:
with urllib.request.urlopen(req) as r:
return r.status, r.read().decode()
except urllib.error.HTTPError as e:
return e.code, e.read().decode()[:400]
since=(datetime.now(timezone.utc)-timedelta(days=30)).date().isoformat()
gads=ROOT/'.ops'/'gads'
total_rows=0; total_spend=0.0
for path in sorted(gads.glob('*.json')):
cid=path.stem
tid=MAP.get(cid)
if not tid:
print('skip unknown', cid); continue
rows_raw=json.loads(path.read_text())
if not rows_raw:
print(cid, 'EMPTY'); continue
out=[]
for r in rows_raw:
date=r.get('segments.date') or (r.get('segments') or {}).get('date')
if not date: continue
camp_id=r.get('campaign.id') or (r.get('campaign') or {}).get('id')
camp_name=r.get('campaign.name') or (r.get('campaign') or {}).get('name')
imp=r.get('metrics.impressions') or (r.get('metrics') or {}).get('impressions') or 0
clicks=r.get('metrics.clicks') or (r.get('metrics') or {}).get('clicks') or 0
cost=r.get('metrics.cost_micros') or (r.get('metrics') or {}).get('costMicros') or (r.get('metrics') or {}).get('cost_micros') or 0
conv=r.get('metrics.conversions') or (r.get('metrics') or {}).get('conversions') or 0
cid_s = str(camp_id) if camp_id is not None else ''
out.append({
'tenant_id': tid,
'date': date,
'platform': 'google',
'campaign_id': cid_s or None,
'campaign_name': camp_name,
# ad_daily.ad_id is NOT NULL — campaign-level rows reuse campaign id
'ad_id': cid_s,
'ad_name': '',
'impressions': int(float(imp or 0)),
'clicks': int(float(clicks or 0)),
'spend': round(float(cost or 0)/1_000_000, 2),
'platform_leads': int(round(float(conv or 0))),
})
call('DELETE', f'/rest/v1/ad_daily?tenant_id=eq.{tid}&platform=eq.google&date=gte.{since}')
ok=True
for i in range(0,len(out),400):
st,res=call('POST','/rest/v1/ad_daily', out[i:i+400])
if st not in (200,201):
print('FAIL', cid, st, res); ok=False; break
if ok:
spend=sum(x['spend'] for x in out)
total_rows+=len(out); total_spend+=spend
print(f'OK {cid} rows={len(out)} spend={spend:.2f}')
print('TOTAL', total_rows, round(total_spend,2))

View File

@@ -0,0 +1,53 @@
import { config } from 'dotenv';
import { resolve } from 'path';
import { writeFileSync, mkdirSync } from 'fs';
config({ path: resolve(process.cwd(), '.env.local') });
import { getValidAccessToken } from '../src/lib/google-drive';
const ID = '1YPcHBjioiHHQy22Q2xevkvUqA5jMq87v6IviV7VTL2s';
const OCT = '1-Zss1suBLDxAgqUuP9DrJYWHiicyin8aQqMOTIIekAg';
async function main() {
const token = await getValidAccessToken();
mkdirSync('/tmp/bps-cal', { recursive: true });
for (const sid of [ID, OCT]) {
const r = await fetch(
`https://sheets.googleapis.com/v4/spreadsheets/${sid}?fields=properties.title,sheets.properties`,
{ headers: { Authorization: `Bearer ${token}` } }
);
const j = (await r.json()) as {
properties?: { title?: string };
sheets?: { properties?: { title?: string; sheetId?: number } }[];
error?: unknown;
};
console.log('status', r.status, JSON.stringify(j).slice(0, 500));
console.log('\n===', j.properties?.title, '===');
for (const s of j.sheets || []) {
const title = s.properties?.title || '';
console.log(' sheet:', title);
const csv = await fetch(
`https://docs.google.com/spreadsheets/d/${sid}/export?format=csv&gid=${s.properties?.sheetId ?? 0}`,
{ headers: { Authorization: `Bearer ${token}` }, redirect: 'follow' }
);
// Sheets export via docs URL may need alternate - use values API
const vals = await fetch(
`https://sheets.googleapis.com/v4/spreadsheets/${sid}/values/${encodeURIComponent(title)}!A1:Z80`,
{ headers: { Authorization: `Bearer ${token}` } }
);
const vj = (await vals.json()) as { values?: string[][] };
const rows = vj.values || [];
const safe = title.replace(/[^\w.-]+/g, '_');
writeFileSync(
`/tmp/bps-cal/${sid.slice(0, 6)}_${safe}.json`,
JSON.stringify(rows, null, 2)
);
console.log(' rows', rows.length, 'head0', (rows[0] || []).slice(0, 8).join(' | '));
console.log(' head1', (rows[1] || []).slice(0, 8).join(' | '));
}
}
}
main().catch((e) => {
console.error(e);
process.exit(1);
});

View File

@@ -0,0 +1,64 @@
/** List Drive calendar folder and find BPS files. */
import { config } from 'dotenv';
import { resolve } from 'path';
config({ path: resolve(process.cwd(), '.env.local') });
import { getValidAccessToken } from '../src/lib/google-drive';
const FOLDER = '12tPM0_nuVEf9MBUeyipmcWAJ-gFFgqXh';
async function listChildren(token: string, parentId: string) {
const out: { id: string; name: string; mimeType: string }[] = [];
let pageToken: string | undefined;
do {
const params = new URLSearchParams({
q: `'${parentId}' in parents and trashed=false`,
fields: 'nextPageToken,files(id,name,mimeType,modifiedTime)',
pageSize: '100',
supportsAllDrives: 'true',
includeItemsFromAllDrives: 'true',
corpora: 'allDrives',
});
if (pageToken) params.set('pageToken', pageToken);
const r = await fetch(`https://www.googleapis.com/drive/v3/files?${params}`, {
headers: { Authorization: `Bearer ${token}` },
});
const j = (await r.json()) as {
files?: { id: string; name: string; mimeType: string }[];
nextPageToken?: string;
};
if (!r.ok) throw new Error(JSON.stringify(j).slice(0, 400));
out.push(...(j.files || []));
pageToken = j.nextPageToken;
} while (pageToken);
return out;
}
async function main() {
const token = await getValidAccessToken();
console.log('token ok');
const top = await listChildren(token, FOLDER);
console.log('top count', top.length);
for (const f of top.sort((a, b) => a.name.localeCompare(b.name))) {
const kind = f.mimeType.includes('folder') ? 'DIR' : 'FILE';
console.log(`${kind}\t${f.name}\t${f.id}`);
}
const bps = top.filter((f) => /bps|blue.?parrot|parrot/i.test(f.name));
console.log('\nBPS matches', bps.length);
for (const f of bps) {
if (f.mimeType.includes('folder')) {
const kids = await listChildren(token, f.id);
console.log(`\n--- ${f.name} (${kids.length}) ---`);
for (const k of kids.sort((a, b) => a.name.localeCompare(b.name))) {
console.log(
` ${k.mimeType.includes('folder') ? 'DIR' : 'FILE'}\t${k.name}\t${k.id}`
);
}
}
}
}
main().catch((e) => {
console.error(e);
process.exit(1);
});

View File

@@ -0,0 +1,101 @@
import { config } from 'dotenv';
import { resolve } from 'path';
config({ path: resolve(process.cwd(), '.env.local') });
import { getValidAccessToken } from '../src/lib/google-drive';
import {
buildSeguimientoRowsForDoc,
upsertSeguimientoRows,
patchSuperhumanIntegration,
findSeguimientoGoogleDoc,
exportGoogleDocHtml,
splitHtmlNotesByDay,
} from '../src/lib/superhuman-docs';
const BPS = '8bec977a-6032-4759-aa7f-b5042081f450';
const DOC = '7TGywaNiUY';
async function driveSearch(q: string) {
const token = await getValidAccessToken();
const params = new URLSearchParams({
q,
fields: 'files(id,name,mimeType,webViewLink,modifiedTime)',
pageSize: '30',
corpora: 'allDrives',
includeItemsFromAllDrives: 'true',
supportsAllDrives: 'true',
});
const r = await fetch(`https://www.googleapis.com/drive/v3/files?${params}`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!r.ok) throw new Error(await r.text());
return ((await r.json()) as { files?: unknown[] }).files || [];
}
async function main() {
console.log(
'search seguimiento+parrot',
await driveSearch(
"name contains 'Seguimiento' and name contains 'Parrot' and trashed=false"
)
);
console.log(
'search seguimiento+BPS',
await driveSearch(
"name contains 'Seguimiento' and name contains 'BPS' and trashed=false"
)
);
console.log(
'search Blue Parrot docs',
await driveSearch(
"name contains 'Blue Parrot' and mimeType='application/vnd.google-apps.document' and trashed=false"
)
);
console.log(
'findSeguimiento',
await findSeguimientoGoogleDoc([
'Seguimiento Semanal Blue Parrot School',
'Seguimiento Semanal BPS',
'Seguimiento Semanal Blue Parrot',
'Blue Parrot School',
'BPS',
])
);
// Re-run full build (includes Drive embed discovery from Superhuman pages)
const rows = await buildSeguimientoRowsForDoc({
tenantId: BPS,
tenantName: 'Blue Parrot School',
docId: DOC,
maxEntries: 120,
exportBodies: true,
});
console.log(
'rows',
rows.length,
rows.slice(0, 8).map((r) => ({
title: r.title,
occurred: r.occurred_at,
ext: r.external_id,
bodyLen: (r.body_md || '').length,
}))
);
const n = await upsertSeguimientoRows(rows);
await patchSuperhumanIntegration(BPS, {
status: 'connected',
external_account_id: DOC,
label: '01.BLUE PARROT SCHOOL',
last_sync_at: new Date().toISOString(),
error_message: null,
meta: {
doc_name: '01.BLUE PARROT SCHOOL',
entries: n,
synced_at: new Date().toISOString(),
},
});
console.log('upserted', n);
}
main().catch((e) => {
console.error(e);
process.exit(1);
});

View File

@@ -0,0 +1,52 @@
import { config } from 'dotenv';
import { resolve } from 'path';
config({ path: resolve(process.cwd(), '.env.local') });
import {
listDocs,
bestDocForTenant,
cleanClientDocName,
} from '../src/lib/superhuman-docs';
import { scoreNameToTenant } from '../src/lib/match';
import { findSeguimientoGoogleDoc } from '../src/lib/superhuman-docs';
async function main() {
const docs = await listDocs();
console.log('docs', docs.length);
for (const d of docs) {
const clean = cleanClientDocName(d.name);
const s1 = scoreNameToTenant(clean, 'BPS', 'bps');
const s2 = scoreNameToTenant(clean, 'Blue Parrot School', 'blue-parrot');
if (
s1 >= 15 ||
s2 >= 15 ||
/parrot|bps|blue/i.test(d.name)
) {
console.log({ name: d.name, clean, s1, s2, id: d.id });
}
}
console.log(
'best BPS',
bestDocForTenant({ name: 'BPS', slug: 'bps' }, docs)
);
console.log(
'best Blue Parrot School',
bestDocForTenant(
{ name: 'Blue Parrot School', slug: 'blue-parrot-school' },
docs
)
);
const drive = await findSeguimientoGoogleDoc([
'Seguimiento Semanal BPS',
'Seguimiento Semanal Blue Parrot School',
'Seguimiento Semanal Blue Parrot',
'BPS',
'Blue Parrot School',
]);
console.log('drive hit', drive);
}
main().catch((e) => {
console.error(e);
process.exit(1);
});

View File

@@ -0,0 +1,41 @@
import { config } from 'dotenv';
import { resolve } from 'path';
config({ path: resolve(process.cwd(), '.env.local') });
import {
exportGoogleDocHtml,
splitHtmlNotesByDay,
splitNotesByDay,
} from '../src/lib/superhuman-docs';
const FILE = '1uDnRPWf1Dqx5b9L3TuhfvutyZpSaifz7YFNrRy_eBhw';
async function main() {
const html = await exportGoogleDocHtml(FILE);
console.log('html len', html.length);
console.log('sample', html.slice(0, 1500));
// find date-like patterns
const dates = html.match(/\d{1,2}[\/\-.]\d{1,2}[\/\-.]\d{2,4}/g) || [];
console.log('date-like', [...new Set(dates)].slice(0, 40));
const headings = html.match(/<h[1-6][^>]*>[\s\S]*?<\/h[1-6]>/gi) || [];
console.log(
'headings',
headings.slice(0, 20).map((h) => h.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim())
);
const secs = splitHtmlNotesByDay(html);
console.log('html sections', secs.length, secs.slice(0, 3).map((s) => s.label));
const plain = html
.replace(/<style[\s\S]*?<\/style>/gi, '')
.replace(/<[^>]+>/g, '\n');
const plainSecs = splitNotesByDay(plain);
console.log(
'plain sections',
plainSecs.length,
plainSecs.slice(0, 5).map((s) => s.label)
);
console.log('plain sample\n', plain.slice(0, 2000));
}
main().catch((e) => {
console.error(e);
process.exit(1);
});

View File

@@ -0,0 +1,96 @@
/**
* Provision victor@4ventures.es as BPS client portal user.
* npx tsx scripts/provision_victor_bps.ts
*/
import { config } from 'dotenv';
import { resolve } from 'path';
import { writeFileSync } from 'fs';
import { createClient } from '@supabase/supabase-js';
import { randomBytes } from 'crypto';
config({ path: resolve(process.cwd(), '.env.local') });
const BPS = '8bec977a-6032-4759-aa7f-b5042081f450';
const EMAIL = 'victor@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 NEXT_PUBLIC_SUPABASE_URL / SUPABASE_SERVICE_ROLE_KEY');
const admin = createClient(url, key, {
auth: { persistSession: false, autoRefreshToken: false },
});
const password = `BpsVictor-${randomBytes(4).toString('hex')}!9`;
let found: { id: string; app_metadata?: Record<string, unknown>; user_metadata?: Record<string, unknown> } | null =
null;
for (let page = 1; page <= 30; page++) {
const { data, error } = await admin.auth.admin.listUsers({ page, perPage: 200 });
if (error) throw error;
const hit = data.users.find((u) => (u.email || '').toLowerCase() === EMAIL);
if (hit) {
found = hit;
break;
}
if (data.users.length < 200) break;
}
const app_metadata = {
...(found?.app_metadata || {}),
role: 'client',
tenant_id: BPS,
company: 'Blue Parrot School',
};
const user_metadata = {
...(found?.user_metadata || {}),
role: 'client',
tenant_id: BPS,
company: 'Blue Parrot School',
};
if (found) {
const { error } = await admin.auth.admin.updateUserById(found.id, {
password,
email_confirm: true,
app_metadata,
user_metadata,
});
if (error) throw error;
console.log('updated', found.id);
} else {
const { data, error } = await admin.auth.admin.createUser({
email: EMAIL,
password,
email_confirm: true,
app_metadata,
user_metadata,
});
if (error) throw error;
console.log('created', data.user?.id);
}
writeFileSync(
resolve(process.cwd(), '.ops/victor-bps-credentials.txt'),
[
`email=${EMAIL}`,
`password=${password}`,
`tenant=Blue Parrot School (${BPS})`,
`role=client`,
`updated_at=${new Date().toISOString()}`,
'',
].join('\n'),
{ mode: 0o600 }
);
console.log('---');
console.log('url https://app.4ventures.es/login');
console.log('email', EMAIL);
console.log('password', password);
}
main().catch((e) => {
console.error(e);
process.exit(1);
});

View File

@@ -0,0 +1,592 @@
#!/usr/bin/env python3
"""Refresh Meta + Google agency matches into client fichas and audit."""
from __future__ import annotations
import json
import re
import urllib.error
import urllib.parse
import urllib.request
from collections import defaultdict
from datetime import datetime, timezone, timedelta
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
META = "https://graph.facebook.com/v21.0"
GADS = "https://googleads.googleapis.com/v18"
def load_env() -> dict[str, str]:
vals: dict[str, str] = {}
for p in (ROOT / ".env.local", Path("/Users/4ventures/Projects/bps-facturacion/.env")):
if not p.is_file():
continue
for line in p.read_text(encoding="utf-8").splitlines():
if not line.strip() or line.strip().startswith("#") or "=" not in line:
continue
k, v = line.split("=", 1)
vals.setdefault(k.strip(), v.strip().strip('"').strip("'"))
return vals
ENV = load_env()
BASE = ENV["NEXT_PUBLIC_SUPABASE_URL"].rstrip("/")
KEY = ENV["SUPABASE_SERVICE_ROLE_KEY"]
SH = {"apikey": KEY, "Authorization": f"Bearer {KEY}"}
def sb_get(path: str):
req = urllib.request.Request(BASE + path, headers=SH)
with urllib.request.urlopen(req, timeout=90) as r:
return json.loads(r.read().decode())
def sb_patch(path: str, body: dict) -> bool:
data = json.dumps(body).encode()
req = urllib.request.Request(
BASE + path,
data=data,
headers={**SH, "Content-Type": "application/json", "Prefer": "return=minimal"},
method="PATCH",
)
try:
with urllib.request.urlopen(req, timeout=60) as r:
return 200 <= r.status < 300
except urllib.error.HTTPError:
return False
def sb_upsert_integ(tenant_id: str, provider: str, patch: dict) -> bool:
path = (
f"/rest/v1/integrations?tenant_id=eq.{urllib.parse.quote(tenant_id)}"
f"&provider=eq.{urllib.parse.quote(provider)}"
)
body = {**patch, "updated_at": datetime.now(timezone.utc).isoformat()}
if sb_patch(path, body):
return True
data = json.dumps(
{"tenant_id": tenant_id, "provider": provider, "enabled": True, **body}
).encode()
req = urllib.request.Request(
BASE + "/rest/v1/integrations",
data=data,
headers={
**SH,
"Content-Type": "application/json",
"Prefer": "resolution=merge-duplicates,return=minimal",
},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=60) as _:
return True
except urllib.error.HTTPError as e:
print("upsert fail", provider, e.read().decode()[:200])
return False
def norm(s: str) -> str:
s = s.lower()
s = (
s.replace("á", "a")
.replace("é", "e")
.replace("í", "i")
.replace("ó", "o")
.replace("ú", "u")
.replace("ñ", "n")
)
return re.sub(r"[^a-z0-9]+", " ", s).strip()
def score_name(tenant_name: str, tenant_slug: str, account_name: str) -> int:
t = norm(tenant_name)
s = norm(tenant_slug.replace("-", " "))
a = norm(account_name)
if not a:
return 0
if a == t or a == s:
return 100
if t and (t in a or a in t):
return 85
if s and (s in a or a in s):
return 80
tt = set(x for x in t.split() if len(x) > 2)
aa = set(x for x in a.split() if len(x) > 2)
if not tt or not aa:
return 0
hit = len(tt & aa) / len(tt)
if hit >= 0.8:
return 70
if hit >= 0.5:
return 55
return int(hit * 50)
def best_matches(tenants, accounts, threshold=55):
"""Greedy 1:1 match like portal."""
scored = []
for t in tenants:
for acc in accounts:
sc = score_name(t["name"], t["slug"], acc["name"])
if sc >= threshold:
scored.append((sc, t["id"], acc))
scored.sort(key=lambda x: -x[0])
used_t, used_a = set(), set()
out = {}
for sc, tid, acc in scored:
if tid in used_t or acc["id"] in used_a:
continue
used_t.add(tid)
used_a.add(acc["id"])
out[tid] = (acc, sc)
return out
def meta_get(token: str, path: str):
url = f"{META}/{path}"
sep = "&" if "?" in path else "?"
req = urllib.request.Request(url + sep + "access_token=" + urllib.parse.quote(token))
with urllib.request.urlopen(req, timeout=90) as r:
return json.loads(r.read().decode())
def meta_paginate(token: str, path: str):
out = []
url = f"{META}/{path}"
sep = "&" if "?" in path else "?"
url = url + sep + "access_token=" + urllib.parse.quote(token)
while url:
req = urllib.request.Request(url)
with urllib.request.urlopen(req, timeout=90) as r:
j = json.loads(r.read().decode())
out.extend(j.get("data") or [])
url = (j.get("paging") or {}).get("next")
return out
def google_refresh(refresh: str) -> str:
body = urllib.parse.urlencode(
{
"refresh_token": refresh,
"client_id": ENV["GOOGLE_OAUTH_CLIENT_ID"],
"client_secret": ENV["GOOGLE_OAUTH_CLIENT_SECRET"],
"grant_type": "refresh_token",
}
).encode()
req = urllib.request.Request(
"https://oauth2.googleapis.com/token",
data=body,
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
with urllib.request.urlopen(req, timeout=60) as r:
return json.loads(r.read().decode())["access_token"]
def gads_access() -> str:
refresh = ENV.get("GOOGLE_ADS_REFRESH_TOKEN") or ENV.get("GOOGLE_OAUTH_REFRESH_TOKEN")
cid = ENV.get("GOOGLE_ADS_CLIENT_ID") or ENV.get("GOOGLE_OAUTH_CLIENT_ID")
secret = ENV.get("GOOGLE_ADS_CLIENT_SECRET") or ENV.get("GOOGLE_OAUTH_CLIENT_SECRET")
body = urllib.parse.urlencode(
{
"refresh_token": refresh,
"client_id": cid,
"client_secret": secret,
"grant_type": "refresh_token",
}
).encode()
req = urllib.request.Request(
"https://oauth2.googleapis.com/token",
data=body,
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
with urllib.request.urlopen(req, timeout=60) as r:
return json.loads(r.read().decode())["access_token"]
def main() -> int:
tenants = sb_get("/rest/v1/tenants?select=id,name,slug&order=name.asc")
print(f"tenants={len(tenants)}")
# --- tokens ---
g_rows = sb_get(
"/rest/v1/agency_oauth?provider=eq.google_drive&select=access_token,refresh_token,expires_at,account_email,scopes"
)
m_rows = sb_get(
"/rest/v1/agency_oauth?provider=eq.meta_ads&select=access_token,account_email,scopes,updated_at"
)
if not g_rows:
print("FAIL: no google_drive oauth")
return 1
if not m_rows:
print("FAIL: no meta_ads oauth")
return 1
grow = g_rows[0]
mrow = m_rows[0]
print("Google:", grow.get("account_email"))
print("Meta:", mrow.get("account_email"), "updated", mrow.get("updated_at"))
gtoken = grow["access_token"]
exp = grow.get("expires_at")
if exp:
try:
et = datetime.fromisoformat(exp.replace("Z", "+00:00"))
if et.timestamp() < datetime.now(timezone.utc).timestamp() + 60:
print("Refreshing Google access token…")
gtoken = google_refresh(grow["refresh_token"])
except Exception as e:
print("google refresh warn", e)
gtoken = google_refresh(grow["refresh_token"])
mtoken = mrow["access_token"]
now = datetime.now(timezone.utc).isoformat()
linked_counts = defaultdict(int)
# ========== META ==========
print("\n=== META Ads + Pages + IG ===")
ad_accounts = []
for row in meta_paginate(
mtoken, "me/adaccounts?fields=id,account_id,name,account_status&limit=200"
):
aid = row.get("account_id") or row["id"].replace("act_", "")
ad_accounts.append({"id": f"act_{aid}", "name": row.get("name") or aid})
print(f"Meta ad accounts visible: {len(ad_accounts)}")
pages = []
igs = []
for page in meta_paginate(
mtoken,
"me/accounts?fields=id,name,instagram_business_account{id,username,name}&limit=100",
):
pages.append({"id": page["id"], "name": page.get("name") or page["id"]})
ig = page.get("instagram_business_account") or {}
if ig.get("id"):
igs.append(
{
"id": ig["id"],
"name": f"@{ig['username']}" if ig.get("username") else (ig.get("name") or page.get("name") or ig["id"]),
}
)
print(f"FB pages visible: {len(pages)} · IG: {len(igs)}")
for provider, accounts, label in (
("meta_ads", ad_accounts, "Meta Ads"),
("facebook", pages, "Facebook Page"),
("instagram", igs, "Instagram"),
):
matches = best_matches(tenants, accounts, threshold=55)
for tid, (acc, sc) in matches.items():
ok = sb_upsert_integ(
tid,
provider,
{
"status": "connected",
"external_account_id": acc["id"],
"label": acc["name"],
"last_sync_at": now,
"error_message": None,
"enabled": True,
"meta": {
"account_name": acc["name"],
"match_score": sc,
"matched_at": now,
"source": "refresh_script",
},
},
)
if ok:
linked_counts[provider] += 1
print(f" linked {provider}: {linked_counts[provider]}/{len(accounts)}")
# ========== GOOGLE DRIVE ==========
print("\n=== Google Drive folders ===")
folders = []
page_token = None
while True:
params = {
"q": "mimeType='application/vnd.google-apps.folder' and trashed=false",
"fields": "nextPageToken,files(id,name)",
"pageSize": "1000",
"corpora": "allDrives",
"includeItemsFromAllDrives": "true",
"supportsAllDrives": "true",
}
if page_token:
params["pageToken"] = page_token
req = urllib.request.Request(
"https://www.googleapis.com/drive/v3/files?" + urllib.parse.urlencode(params),
headers={"Authorization": f"Bearer {gtoken}"},
)
with urllib.request.urlopen(req, timeout=90) as r:
j = json.loads(r.read().decode())
for f in j.get("files") or []:
folders.append({"id": f["id"], "name": f["name"]})
page_token = j.get("nextPageToken")
if not page_token:
break
print(f"Drive folders: {len(folders)}")
matches = best_matches(tenants, folders, threshold=55)
for tid, (acc, sc) in matches.items():
if sb_upsert_integ(
tid,
"google_drive",
{
"status": "connected",
"external_account_id": acc["id"],
"label": acc["name"],
"last_sync_at": now,
"error_message": None,
"meta": {"folder_name": acc["name"], "match_score": sc, "source": "refresh_script"},
},
):
linked_counts["google_drive"] += 1
print(f" linked google_drive: {linked_counts['google_drive']}")
# ========== GA4 ==========
print("\n=== GA4 properties ===")
ga4 = []
req = urllib.request.Request(
"https://analyticsadmin.googleapis.com/v1beta/accountSummaries",
headers={"Authorization": f"Bearer {gtoken}"},
)
with urllib.request.urlopen(req, timeout=90) as r:
summaries = json.loads(r.read().decode())
for acc in summaries.get("accountSummaries") or []:
for p in acc.get("propertySummaries") or []:
prop = p.get("property")
if not prop:
continue
# measurement id
mid = None
try:
req2 = urllib.request.Request(
f"https://analyticsadmin.googleapis.com/v1beta/{prop}/dataStreams",
headers={"Authorization": f"Bearer {gtoken}"},
)
with urllib.request.urlopen(req2, timeout=60) as r2:
streams = json.loads(r2.read().decode())
for s in streams.get("dataStreams") or []:
mid = (s.get("webStreamData") or {}).get("measurementId")
if mid:
break
except Exception:
pass
ga4.append(
{
"id": mid or prop,
"name": p.get("displayName") or prop,
"property_id": prop,
"measurement_id": mid,
}
)
print(f"GA4 properties: {len(ga4)}")
matches = best_matches(tenants, ga4, threshold=50)
for tid, (acc, sc) in matches.items():
if sb_upsert_integ(
tid,
"ga4",
{
"status": "connected",
"external_account_id": acc["id"],
"label": acc["name"],
"last_sync_at": now,
"error_message": None,
"meta": {
"property_id": acc.get("property_id"),
"measurement_id": acc.get("measurement_id"),
"display_name": acc["name"],
"match_score": sc,
"source": "refresh_script",
},
},
):
linked_counts["ga4"] += 1
print(f" linked ga4: {linked_counts['ga4']}")
# ========== GSC ==========
print("\n=== Search Console ===")
req = urllib.request.Request(
"https://www.googleapis.com/webmasters/v3/sites",
headers={"Authorization": f"Bearer {gtoken}"},
)
with urllib.request.urlopen(req, timeout=90) as r:
sites_j = json.loads(r.read().decode())
gsc = []
for s in sites_j.get("siteEntry") or []:
url = s.get("siteUrl")
if url:
gsc.append({"id": url, "name": url})
print(f"GSC sites: {len(gsc)}")
matches = best_matches(tenants, gsc, threshold=40)
for tid, (acc, sc) in matches.items():
if sb_upsert_integ(
tid,
"search_console",
{
"status": "connected",
"external_account_id": acc["id"],
"label": acc["id"],
"last_sync_at": now,
"error_message": None,
"meta": {"site_url": acc["id"], "match_score": sc, "source": "refresh_script"},
},
):
linked_counts["gsc"] += 1
print(f" linked gsc: {linked_counts['gsc']}")
# ========== Google Ads ==========
print("\n=== Google Ads ===")
try:
at = gads_access()
headers = {
"Authorization": f"Bearer {at}",
"developer-token": ENV["GOOGLE_ADS_DEVELOPER_TOKEN"],
"Content-Type": "application/json",
}
login = (ENV.get("GOOGLE_ADS_LOGIN_CUSTOMER_ID") or "").replace("-", "")
if login:
headers["login-customer-id"] = login
req = urllib.request.Request(
f"{GADS}/customers:listAccessibleCustomers", headers=headers
)
with urllib.request.urlopen(req, timeout=90) as r:
listed = json.loads(r.read().decode())
ids = [rn.replace("customers/", "") for rn in listed.get("resourceNames") or []]
gads_accounts = []
for cid in ids:
h = dict(headers)
h["login-customer-id"] = cid
body = json.dumps(
{
"query": "SELECT customer.id, customer.descriptive_name, customer.manager FROM customer LIMIT 1"
}
).encode()
req = urllib.request.Request(
f"{GADS}/customers/{cid}/googleAds:search",
data=body,
headers=h,
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=60) as r:
j = json.loads(r.read().decode())
except Exception:
continue
c = ((j.get("results") or [{}])[0]).get("customer") or {}
if not c:
continue
name = c.get("descriptiveName") or cid
if c.get("manager"):
# pull clients
body2 = json.dumps(
{
"query": (
"SELECT customer_client.id, customer_client.descriptive_name, "
"customer_client.manager, customer_client.status FROM customer_client "
"WHERE customer_client.status = 'ENABLED' AND customer_client.manager = FALSE"
)
}
).encode()
req2 = urllib.request.Request(
f"{GADS}/customers/{cid}/googleAds:search",
data=body2,
headers=h,
method="POST",
)
try:
with urllib.request.urlopen(req2, timeout=90) as r2:
j2 = json.loads(r2.read().decode())
for row in j2.get("results") or []:
cc = row.get("customerClient") or {}
if cc.get("id"):
gads_accounts.append(
{
"id": str(cc["id"]),
"name": cc.get("descriptiveName") or str(cc["id"]),
}
)
except Exception:
pass
else:
gads_accounts.append({"id": str(c.get("id") or cid), "name": name})
# dedupe
mmap = {a["id"]: a for a in gads_accounts}
gads_accounts = list(mmap.values())
print(f"Google Ads accounts: {len(gads_accounts)}")
matches = best_matches(tenants, gads_accounts, threshold=55)
for tid, (acc, sc) in matches.items():
if sb_upsert_integ(
tid,
"google_ads",
{
"status": "connected",
"external_account_id": acc["id"],
"label": acc["name"],
"last_sync_at": now,
"error_message": None,
"meta": {
"account_name": acc["name"],
"customer_id": acc["id"],
"match_score": sc,
"source": "refresh_script",
},
},
):
linked_counts["google_ads"] += 1
print(f" linked google_ads: {linked_counts['google_ads']}")
except Exception as e:
print("Google Ads ERROR:", e)
# ========== AUDIT ==========
print("\n=== AUDIT ===")
integs = sb_get(
"/rest/v1/integrations?provider=in.(ga4,search_console,google_drive,google_ads,meta_ads,facebook,instagram)"
"&select=tenant_id,provider,status,external_account_id,error_message"
)
by = defaultdict(dict)
for i in integs:
by[i["tenant_id"]][i["provider"]] = i
def flag(i):
if not i:
return "—"
if i.get("status") == "connected" and i.get("external_account_id"):
return "OK"
if i.get("status") == "error":
return "ERR"
return "—"
print(
f"{'Cliente':30} {'GAds':5} {'Meta':5} {'GA4':5} {'GSC':5} {'Drive':5} {'FB':5} {'IG':5} Org"
)
for t in tenants:
m = by[t["id"]]
# organic count
req = urllib.request.Request(
f"{BASE}/rest/v1/organic_daily?tenant_id=eq.{t['id']}&select=day",
headers={**SH, "Prefer": "count=exact", "Range": "0-0"},
)
with urllib.request.urlopen(req, timeout=30) as r:
cr = r.headers.get("content-range") or "*/0"
org = cr.split("/")[-1]
print(
f"{t['name'][:30]:30} "
f"{flag(m.get('google_ads')):5} "
f"{flag(m.get('meta_ads')):5} "
f"{flag(m.get('ga4')):5} "
f"{flag(m.get('search_console')):5} "
f"{flag(m.get('google_drive')):5} "
f"{flag(m.get('facebook')):5} "
f"{flag(m.get('instagram')):5} "
f"{org}"
)
print("\n=== Linked counts this run ===")
print(dict(linked_counts))
print("\nDone. Orgánico days still need app sync (Emparejar todo Google / abrir Orgánico).")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,203 @@
/**
* One-shot: bootstrap Google + discover Meta → fichas, then print audit.
* Usage: npx tsx --env-file=.env.local scripts/refresh_agency_connections.ts
*/
import { bootstrapAgencyGoogle } from '../src/lib/agency-google-bootstrap';
import { discoverAndMatch } from '../src/lib/ads-discovery';
import { serviceHeaders, supabaseUrl } from '../src/lib/agency';
async function get(path: string) {
const headers = serviceHeaders();
if (!headers) throw new Error('no service role');
const r = await fetch(`${supabaseUrl()}${path}`, {
headers,
cache: 'no-store',
});
if (!r.ok) throw new Error(`${path}: ${await r.text()}`);
return r.json();
}
async function organicCount(tenantId: string): Promise<number> {
const headers = serviceHeaders();
if (!headers) return 0;
const r = await fetch(
`${supabaseUrl()}/rest/v1/organic_daily?tenant_id=eq.${tenantId}&select=day`,
{
headers: { ...headers, Prefer: 'count=exact', Range: '0-0' },
cache: 'no-store',
}
);
const cr = r.headers.get('content-range') || '';
if (cr.includes('/')) return Number(cr.split('/').pop()) || 0;
return 0;
}
async function main() {
console.log('=== 1. Google bootstrap (Drive + GA4 + GSC + Ads + sync) ===');
const boot = await bootstrapAgencyGoogle({
includeGoogleAds: true,
syncOrganic: true,
organicDays: 28,
});
console.log(JSON.stringify(boot, null, 2));
console.log('\n=== 2. Meta discover (Ads + FB + IG) ===');
const meta = await discoverAndMatch({
apply: true,
providers: ['meta_ads', 'google_ads'],
});
console.log(
JSON.stringify(
{
meta: {
accounts: meta.meta?.accounts,
linked: meta.meta?.linked,
pagesLinked: meta.meta?.pagesLinked,
igLinked: meta.meta?.igLinked,
error: meta.meta?.error,
},
google: {
accounts: meta.google?.accounts,
linked: meta.google?.linked,
error: meta.google?.error,
},
},
null,
2
)
);
console.log('\n=== 3. Audit por cliente ===');
const tenants = (await get(
'/rest/v1/tenants?select=id,name,slug&order=name.asc'
)) as { id: string; name: string; slug: string }[];
const integs = (await get(
'/rest/v1/integrations?provider=in.(ga4,search_console,google_drive,google_ads,meta_ads,facebook,instagram)&select=tenant_id,provider,status,external_account_id,error_message,meta'
)) as {
tenant_id: string;
provider: string;
status: string;
external_account_id?: string | null;
error_message?: string | null;
meta?: Record<string, unknown> | null;
}[];
const by = new Map<string, typeof integs>();
for (const i of integs) {
const arr = by.get(i.tenant_id) || [];
arr.push(i);
by.set(i.tenant_id, arr);
}
const providers = [
'google_ads',
'meta_ads',
'ga4',
'search_console',
'google_drive',
'facebook',
'instagram',
] as const;
type Row = {
name: string;
organic_days: number;
channels: Record<string, string>;
gaps: string[];
};
const rows: Row[] = [];
for (const t of tenants) {
const map = new Map(
(by.get(t.id) || []).map((i) => [i.provider, i] as const)
);
const channels: Record<string, string> = {};
const gaps: string[] = [];
for (const p of providers) {
const i = map.get(p);
if (i?.status === 'connected' && i.external_account_id) {
const id = String(i.external_account_id);
channels[p] = id.length > 42 ? id.slice(0, 40) + '…' : id;
} else if (i?.status === 'error') {
channels[p] = `ERR:${(i.error_message || '').slice(0, 40)}`;
gaps.push(p);
} else {
channels[p] = '—';
gaps.push(p);
}
}
const org = await organicCount(t.id);
rows.push({ name: t.name, organic_days: org, channels, gaps });
}
console.log(
[
'Cliente'.padEnd(28),
'GAds',
'Meta',
'GA4',
'GSC',
'Drive',
'FB',
'IG',
'OrgDías',
].join('\t')
);
for (const r of rows) {
const c = r.channels;
const flag = (v: string) => (v === '—' ? '—' : v.startsWith('ERR') ? 'ERR' : 'OK');
console.log(
[
r.name.slice(0, 28).padEnd(28),
flag(c.google_ads),
flag(c.meta_ads),
flag(c.ga4),
flag(c.search_console),
flag(c.google_drive),
flag(c.facebook),
flag(c.instagram),
String(r.organic_days),
].join('\t')
);
}
console.log('\n=== Detalle IDs (solo conectados) ===');
for (const r of rows) {
const linked = Object.entries(r.channels).filter(([, v]) => v !== '—' && !v.startsWith('ERR'));
if (!linked.length && r.organic_days === 0) continue;
console.log(`\n${r.name} · organic_daily=${r.organic_days}`);
for (const [p, v] of linked) console.log(` ${p}: ${v}`);
}
const withAds = rows.filter(
(r) => r.channels.google_ads !== '—' || r.channels.meta_ads !== '—'
).length;
const withOrg = rows.filter(
(r) =>
r.channels.ga4 !== '—' ||
r.channels.search_console !== '—' ||
r.organic_days > 0
).length;
const withSocial = rows.filter(
(r) => r.channels.facebook !== '—' || r.channels.instagram !== '—'
).length;
console.log('\n=== RESUMEN ===');
console.log(
JSON.stringify(
{
tenants: rows.length,
with_ads: withAds,
with_organic_link_or_data: withOrg,
with_fb_or_ig: withSocial,
organic_tenants_with_rows: rows.filter((r) => r.organic_days > 0).length,
},
null,
2
)
);
}
main().catch((e) => {
console.error(e);
process.exit(1);
});

View File

@@ -0,0 +1,67 @@
#!/usr/bin/env python3
"""Authenticated smoke: agencia + simulate."""
from __future__ import annotations
import json
import re
import sys
from pathlib import Path
from playwright.sync_api import sync_playwright
ROOT = Path("/Users/4ventures/4ventures-client-portal")
APP = "https://app.4ventures.es"
CREDS = ROOT / ".ops" / "marketing-credentials.txt"
SHOT = ROOT / "scripts/dokploy-shots"
def main() -> int:
email, password = "marketing@4ventures.es", ""
for line in CREDS.read_text().splitlines():
if line.startswith("email="):
email = line.split("=", 1)[1].strip()
if line.startswith("password="):
password = line.split("=", 1)[1].strip()
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page(viewport={"width": 1400, "height": 1100})
page.goto(f"{APP}/login", wait_until="networkidle", timeout=90000)
page.locator('input[type="email"], input[name="email"]').first.fill(email)
page.locator('input[type="password"]').first.fill(password)
page.locator('button[type="submit"]').first.click()
page.wait_for_timeout(3500)
page.goto(f"{APP}/dashboard/agencia", wait_until="networkidle", timeout=60000)
page.wait_for_timeout(1500)
SHOT.mkdir(parents=True, exist_ok=True)
page.screenshot(path=str(SHOT / "smoke_agencia.png"), full_page=True)
body = page.inner_text("body")
missing_key = "Falta SUPABASE_SERVICE_ROLE_KEY" in body
print("agencia_missing_service_key", missing_key, flush=True)
print("agencia_snip", " | ".join([ln.strip() for ln in body.splitlines() if ln.strip()][10:22]), flush=True)
sim = page.evaluate(
"""async () => {
const r = await fetch('/api/simulate', {
method: 'POST',
headers: {'content-type':'application/json'},
body: JSON.stringify({ amount: 1000, channel: 'meta' })
});
const text = await r.text();
let json=null; try { json=JSON.parse(text); } catch {}
return { status: r.status, text: text.slice(0,250), json };
}"""
)
print("simulate", json.dumps(sim, ensure_ascii=False)[:400], flush=True)
browser.close()
if missing_key:
print("FAIL_AGENCIA", flush=True)
return 2
print("SUCCESS", flush=True)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,77 @@
#!/usr/bin/env python3
"""Authenticated chat smoke on app.4ventures.es."""
from __future__ import annotations
import json
import re
import sys
from pathlib import Path
from playwright.sync_api import sync_playwright
ROOT = Path("/Users/4ventures/4ventures-client-portal")
APP = "https://app.4ventures.es"
CREDS = ROOT / ".ops" / "marketing-credentials.txt"
def main() -> int:
email, password = "marketing@4ventures.es", ""
for line in CREDS.read_text().splitlines():
if line.startswith("email="):
email = line.split("=", 1)[1].strip()
if line.startswith("password="):
password = line.split("=", 1)[1].strip()
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page(viewport={"width": 1400, "height": 1000})
page.goto(f"{APP}/login", wait_until="networkidle", timeout=90000)
page.locator('input[type="email"], input[name="email"]').first.fill(email)
page.locator('input[type="password"]').first.fill(password)
page.locator('button[type="submit"]').first.click()
page.wait_for_timeout(3500)
if "/login" in page.url:
print("LOGIN_FAILED", flush=True)
browser.close()
return 2
# unauth baseline via fresh context later; here authenticated
res = page.evaluate(
"""async () => {
const r = await fetch('/api/chat', {
method: 'POST',
headers: {'content-type':'application/json'},
body: JSON.stringify({ message: 'hola' })
});
const text = await r.text();
let json = null;
try { json = JSON.parse(text); } catch {}
return { status: r.status, text: text.slice(0, 300), json };
}"""
)
print("auth_chat", json.dumps(res, ensure_ascii=False)[:500], flush=True)
browser.close()
# unauth
import urllib.error
import urllib.request
req = urllib.request.Request(
f"{APP}/api/chat",
data=b'{"message":"hola"}',
method="POST",
headers={"Content-Type": "application/json"},
)
try:
with urllib.request.urlopen(req, timeout=15) as r:
print("unauth_chat", r.status, r.read()[:80], flush=True)
except urllib.error.HTTPError as e:
print("unauth_chat", e.code, e.read()[:80], flush=True)
ok = res.get("status") == 200 and isinstance(res.get("json"), list)
print("SUCCESS" if ok else "PARTIAL", flush=True)
return 0 if ok else 5
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,55 @@
#!/usr/bin/env python3
"""Create a test creative draft while logged in as marketing@."""
from __future__ import annotations
import re
import sys
from pathlib import Path
from playwright.sync_api import sync_playwright
ROOT = Path("/Users/4ventures/4ventures-client-portal")
SHOT = ROOT / "scripts/dokploy-shots"
APP = "https://app.4ventures.es"
CREDS = ROOT / ".ops" / "marketing-credentials.txt"
def load_creds():
email, password = "marketing@4ventures.es", ""
for line in CREDS.read_text().splitlines():
if line.startswith("email="):
email = line.split("=", 1)[1].strip()
if line.startswith("password="):
password = line.split("=", 1)[1].strip()
return email, password
def main() -> int:
email, password = load_creds()
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page(viewport={"width": 1400, "height": 1100})
page.goto(f"{APP}/login", wait_until="networkidle", timeout=90000)
page.locator('input[type="email"], input[name="email"]').first.fill(email)
page.locator('input[type="password"]').first.fill(password)
page.locator('button[type="submit"]').first.click()
page.wait_for_timeout(3500)
page.goto(f"{APP}/dashboard/creatives", wait_until="networkidle", timeout=60000)
page.wait_for_timeout(1500)
page.locator("input").first.fill("Smoke test · borrador operador")
page.get_by_role("button", name=re.compile(r"Crear borrador", re.I)).click()
page.wait_for_timeout(2500)
page.screenshot(path=str(SHOT / "creatives_after_create.png"), full_page=True)
body = page.inner_text("body")
ok = "Smoke test" in body and "draft" in body.lower()
print("created_visible", ok, flush=True)
if "error" in body.lower() and "Smoke test" not in body:
print("body", body[-400:].replace("\n", " | "), flush=True)
browser.close()
return 2
browser.close()
return 0 if ok else 3
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,100 @@
#!/usr/bin/env python3
"""Login as marketing@ on app.4ventures.es and smoke dashboard routes."""
from __future__ import annotations
import re
import sys
from pathlib import Path
from playwright.sync_api import sync_playwright
ROOT = Path("/Users/4ventures/4ventures-client-portal")
SHOT = ROOT / "scripts/dokploy-shots"
SHOT.mkdir(parents=True, exist_ok=True)
APP = "https://app.4ventures.es"
CREDS = ROOT / ".ops" / "marketing-credentials.txt"
def load_creds() -> tuple[str, str]:
email = "marketing@4ventures.es"
password = ""
for line in CREDS.read_text(encoding="utf-8").splitlines():
if line.startswith("email="):
email = line.split("=", 1)[1].strip()
if line.startswith("password="):
password = line.split("=", 1)[1].strip()
if not password:
raise SystemExit("missing password in .ops/marketing-credentials.txt")
return email, password
def main() -> int:
email, password = load_creds()
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page(viewport={"width": 1400, "height": 1100})
page.goto(f"{APP}/login", wait_until="networkidle", timeout=90000)
page.wait_for_timeout(1000)
page.screenshot(path=str(SHOT / "app_login.png"), full_page=True)
# Fill login form
email_inp = page.locator('input[type="email"], input[name="email"], input[placeholder*="mail" i]')
pass_inp = page.locator('input[type="password"]')
print("email_inputs", email_inp.count(), "pass_inputs", pass_inp.count(), flush=True)
if not email_inp.count() or not pass_inp.count():
print("LOGIN_FORM_MISSING", flush=True)
print(page.inner_text("body")[:500].replace("\n", " | "), flush=True)
browser.close()
return 2
email_inp.first.fill(email)
pass_inp.first.fill(password)
submit = page.locator('button[type="submit"], button').filter(has_text=re.compile(r"Entrar|Login|Iniciar|Acceder|Sign", re.I))
if submit.count():
submit.first.click()
else:
page.locator('button[type="submit"]').first.click()
page.wait_for_timeout(4000)
page.screenshot(path=str(SHOT / "app_after_login.png"), full_page=True)
print("url", page.url, flush=True)
body = page.inner_text("body")
bad = bool(re.search(r"invalid|incorrect|error|credencial", body, re.I)) and "/login" in page.url
print("still_login", "/login" in page.url, "errorish", bad, flush=True)
print("title_snip", " | ".join([ln.strip() for ln in body.splitlines() if ln.strip()][:12]), flush=True)
if "/login" in page.url:
browser.close()
print("LOGIN_FAILED", flush=True)
return 3
# Smoke routes
results = {}
for path in ("/dashboard", "/dashboard/agencia", "/dashboard/creatives", "/dashboard/kpis"):
page.goto(f"{APP}{path}", wait_until="networkidle", timeout=60000)
page.wait_for_timeout(1200)
code_ish = page.url
txt = page.inner_text("body")[:300].replace("\n", " | ")
results[path] = {"url": code_ish, "snip": txt}
safe = path.strip("/").replace("/", "_") or "root"
page.screenshot(path=str(SHOT / f"app_{safe}.png"), full_page=True)
print(path, "->", code_ish, flush=True)
print(" ", txt[:180], flush=True)
# API with cookies from context
cookies = page.context.cookies()
cookie_header = "; ".join(f"{c['name']}={c['value']}" for c in cookies if "app.4ventures.es" in c.get("domain", "") or c.get("domain", "").endswith("4ventures.es"))
print("cookies", len(cookies), flush=True)
browser.close()
# Outside: health still ok
import urllib.request
with urllib.request.urlopen("https://app.4ventures.es/api/health", timeout=12) as r:
print("health", r.read().decode()[:180], flush=True)
print("SUCCESS_LOGIN", flush=True)
return 0
if __name__ == "__main__":
raise SystemExit(main())

75
scripts/sync_ads_only.ts Normal file
View File

@@ -0,0 +1,75 @@
/**
* Sync Meta + Google Ads → ad_daily (+ kpi_weekly rollup).
*
* Usage:
* npx tsx scripts/sync_ads_only.ts # last 30d all tenants
* DAYS=640 npx tsx scripts/sync_ads_only.ts # backfill ~from Jan 2025
* TENANT_SLUG=bps DAYS=640 npx tsx ...
*/
import { config } from 'dotenv';
import { resolve } from 'path';
config({ path: resolve(process.cwd(), '.env.local') });
import {
ADS_BACKFILL_MAX_DAYS,
syncAdsForTenants,
} from '../src/lib/ads-sync';
async function main() {
const slug = process.env.TENANT_SLUG?.trim();
let tenantId: string | undefined;
if (slug) {
const { serviceHeaders, supabaseUrl } = await import('../src/lib/agency');
const headers = serviceHeaders()!;
const r = await fetch(
`${supabaseUrl()}/rest/v1/tenants?slug=eq.${encodeURIComponent(slug)}&select=id&limit=1`,
{ headers }
);
const rows = (await r.json()) as { id: string }[];
tenantId = rows[0]?.id;
if (!tenantId) throw new Error(`Tenant slug not found: ${slug}`);
}
// Days since 2025-01-01 (inclusive) when DAYS unset and BACKFILL=1
const backfill = process.env.BACKFILL === '1' || process.env.BACKFILL === 'true';
const daysFrom2025 = Math.ceil(
(Date.now() - Date.UTC(2025, 0, 1)) / 86400000
);
const rawDays = Number(process.env.DAYS);
const days = Math.min(
ADS_BACKFILL_MAX_DAYS,
Math.max(
1,
Number.isFinite(rawDays) && rawDays > 0
? rawDays
: backfill
? daysFrom2025
: 30
)
);
console.log(
`Ads sync · days=${days} · since≈${new Date(Date.now() - days * 86400000).toISOString().slice(0, 10)}` +
(tenantId ? ` · tenant=${slug || tenantId}` : ' · all tenants')
);
const { results } = await syncAdsForTenants({ tenantId, days });
let ok = 0;
let err = 0;
for (const x of results) {
const rows = x.metaRows + x.googleRows;
if (rows > 0) ok += 1;
if (x.errors.length) err += 1;
console.log(
`${x.tenantName}: meta=${x.metaRows} google=${x.googleRows} weekly=${x.weeklyRows}` +
(x.errors.length
? ` · ERR ${x.errors.join('; ').slice(0, 200)}`
: '')
);
}
console.log(`\nDone · synced=${ok} withErrors=${err} / ${results.length}`);
}
main().catch((e) => {
console.error(e);
process.exit(1);
});

272
scripts/sync_all_data.ts Normal file
View File

@@ -0,0 +1,272 @@
/**
* One-shot sync: organic + ads + superhuman + SEO audits.
* Usage:
* npx tsx -r dotenv/config -r tsconfig-paths/register scripts/sync_all_data.ts
* Optional: TENANT_SLUG=bps
*/
import { config } from 'dotenv';
import { resolve } from 'path';
config({ path: resolve(process.cwd(), '.env.local') });
config({ path: resolve(process.cwd(), '.env') });
import { serviceHeaders, supabaseUrl } from '../src/lib/agency';
import { syncAllConnectedOrganic } from '../src/lib/google-organic';
import { syncAdsForTenants } from '../src/lib/ads-sync';
import { syncAllSuperhumanTenants } from '../src/lib/superhuman-docs';
import { runSeoSiteAudit } from '../src/lib/seo-audit';
import { resolveMetaAccessTokens } from '../src/lib/ads-discovery';
const META = 'https://graph.facebook.com/v21.0';
type Tenant = { id: string; name: string; slug: string };
async function sbGet<T>(path: string): Promise<T> {
const headers = serviceHeaders();
if (!headers) throw new Error('no service role');
const r = await fetch(`${supabaseUrl()}/rest/v1/${path}`, {
headers,
cache: 'no-store',
});
if (!r.ok) throw new Error(`${path}: ${(await r.text()).slice(0, 300)}`);
return (await r.json()) as T;
}
async function sbPatch(path: string, body: unknown) {
const headers = serviceHeaders();
if (!headers) throw new Error('no service role');
const r = await fetch(`${supabaseUrl()}/rest/v1/${path}`, {
method: 'PATCH',
headers: {
...headers,
'Content-Type': 'application/json',
Prefer: 'return=minimal',
},
body: JSON.stringify(body),
});
if (!r.ok) throw new Error(`PATCH ${path}: ${(await r.text()).slice(0, 300)}`);
}
async function sbPost(path: string, body: unknown) {
const headers = serviceHeaders();
if (!headers) throw new Error('no service role');
const r = await fetch(`${supabaseUrl()}/rest/v1/${path}`, {
method: 'POST',
headers: {
...headers,
'Content-Type': 'application/json',
Prefer: 'return=minimal',
},
body: JSON.stringify(body),
});
if (!r.ok) throw new Error(`POST ${path}: ${(await r.text()).slice(0, 400)}`);
}
async function syncSocial(tenants: Tenant[]) {
const tokens = await resolveMetaAccessTokens();
const token = tokens[0];
if (!token) return { ok: false as const, warning: 'Sin META token', results: [] as unknown[] };
const results: { tenant: string; fb?: string; ig?: string }[] = [];
for (const tenant of tenants) {
const integs = await sbGet<
{
provider: string;
external_account_id?: string | null;
status: string;
meta?: Record<string, unknown> | null;
}[]
>(
`integrations?tenant_id=eq.${encodeURIComponent(tenant.id)}&provider=in.(facebook,instagram)&status=eq.connected&select=provider,external_account_id,status,meta`
);
if (!integs.length) continue;
const out: { tenant: string; fb?: string; ig?: string } = {
tenant: tenant.name,
};
for (const i of integs) {
const id = i.external_account_id;
if (!id) continue;
try {
let payload: Record<string, unknown> | null = null;
if (i.provider === 'facebook') {
const r = await fetch(
`${META}/${encodeURIComponent(id)}?fields=name,fan_count,followers_count&access_token=${encodeURIComponent(token)}`,
{ cache: 'no-store' }
);
if (r.ok) {
payload = (await r.json()) as Record<string, unknown>;
out.fb = 'ok';
} else {
out.fb = `err ${(await r.text()).slice(0, 80)}`;
}
} else {
const r = await fetch(
`${META}/${encodeURIComponent(id)}?fields=name,followers_count,media_count&access_token=${encodeURIComponent(token)}`,
{ cache: 'no-store' }
);
if (r.ok) {
payload = (await r.json()) as Record<string, unknown>;
out.ig = 'ok';
} else {
out.ig = `err ${(await r.text()).slice(0, 80)}`;
}
}
if (payload) {
const now = new Date().toISOString();
await sbPatch(
`integrations?tenant_id=eq.${encodeURIComponent(tenant.id)}&provider=eq.${i.provider}`,
{
last_sync_at: now,
error_message: null,
meta: {
...(i.meta || {}),
social_cache: payload,
social_cached_at: now,
},
}
);
}
} catch (e) {
const msg = e instanceof Error ? e.message : 'err';
if (i.provider === 'facebook') out.fb = msg;
else out.ig = msg;
}
}
results.push(out);
}
return { ok: true as const, results };
}
async function syncSeoAudits(tenants: Tenant[]) {
const results: {
tenant: string;
score?: number;
grade?: string;
error?: string;
}[] = [];
for (const tenant of tenants) {
const [gsc, ga4] = await Promise.all([
sbGet<
{ external_account_id?: string | null; meta?: { site_url?: string } }[]
>(
`integrations?tenant_id=eq.${encodeURIComponent(tenant.id)}&provider=eq.search_console&status=eq.connected&select=external_account_id,meta&limit=1`
),
sbGet<{ id: string }[]>(
`integrations?tenant_id=eq.${encodeURIComponent(tenant.id)}&provider=eq.ga4&status=eq.connected&select=id&limit=1`
),
]);
const site =
gsc[0]?.meta?.site_url || gsc[0]?.external_account_id || null;
if (!site) continue;
try {
const audit = await runSeoSiteAudit({
gscSiteUrl: site,
ga4Connected: ga4.length > 0,
gscConnected: true,
});
await sbPost('seo_audits', {
tenant_id: tenant.id,
site_url: audit.siteUrl,
score: audit.score,
grade: audit.grade,
warning_count: audit.warningCount,
critical_count: audit.criticalCount,
checks: audit.checks,
summary: audit.summary,
});
results.push({
tenant: tenant.name,
score: audit.score,
grade: audit.grade,
});
} catch (e) {
results.push({
tenant: tenant.name,
error: e instanceof Error ? e.message.slice(0, 160) : 'audit error',
});
}
}
return results;
}
async function main() {
if (!serviceHeaders()) throw new Error('Falta SUPABASE_SERVICE_ROLE_KEY');
let tenants = await sbGet<Tenant[]>(
'tenants?select=id,name,slug&order=name.asc'
);
const slug = process.env.TENANT_SLUG?.trim();
const tid = process.env.TENANT_ID?.trim();
if (slug) tenants = tenants.filter((t) => t.slug === slug);
if (tid) tenants = tenants.filter((t) => t.id === tid);
console.log(`\n=== SYNC ALL · ${tenants.length} tenant(s) ===\n`);
console.log('--- ORGANIC ---');
const organic = await syncAllConnectedOrganic({
tenantId: tenants.length === 1 ? tenants[0].id : null,
days: 28,
});
for (const r of organic.results) {
const name = tenants.find((t) => t.id === r.tenantId)?.name || r.tenantId;
console.log(
` ${name}: ${r.daysWritten}d${r.warning ? ` · ⚠ ${r.warning}` : ''}`
);
}
console.log('\n--- ADS ---');
const ads = await syncAdsForTenants({
tenantId: tenants.length === 1 ? tenants[0].id : undefined,
days: 30,
});
for (const r of ads.results) {
console.log(
` ${r.tenantName}: meta=${r.metaRows} google=${r.googleRows}` +
(r.errors.length ? ` · ERR ${r.errors.join('; ').slice(0, 100)}` : '')
);
}
console.log('\n--- SUPERHUMAN ---');
try {
const sh = await syncAllSuperhumanTenants({
tenantId: tenants.length === 1 ? tenants[0].id : null,
maxEntries: 100,
exportBodies: true,
});
for (const r of sh.results) {
console.log(
` ${r.tenantName}: ${r.status}` +
(r.upserted ? ` · ${r.upserted}` : '') +
(r.docName ? ` · ${r.docName}` : '') +
(r.error ? ` · ${r.error.slice(0, 80)}` : '')
);
}
} catch (e) {
console.log(' ', e instanceof Error ? e.message : e);
}
console.log('\n--- SOCIAL ---');
const social = await syncSocial(tenants);
if (!social.ok) console.log(' ', social.warning);
for (const r of social.results as { tenant: string; fb?: string; ig?: string }[]) {
console.log(` ${r.tenant}: fb=${r.fb || '-'} ig=${r.ig || '-'}`);
}
console.log('\n--- SEO AUDIT ---');
for (const r of await syncSeoAudits(tenants)) {
console.log(
` ${r.tenant}:` +
(r.grade != null ? ` ${r.grade} (${r.score})` : '') +
(r.error ? ` ERR ${r.error}` : '')
);
}
console.log('\n=== DONE ===\n');
}
main().catch((e) => {
console.error(e);
process.exit(1);
});

View File

@@ -0,0 +1,145 @@
import { config } from 'dotenv';
import { resolve } from 'path';
config({ path: resolve(process.cwd(), '.env.local') });
import {
exportGoogleDocPlain,
exportGoogleDocHtml,
upsertSeguimientoRows,
patchSuperhumanIntegration,
} from '../src/lib/superhuman-docs';
import { getValidAccessToken } from '../src/lib/google-drive';
const BPS = '8bec977a-6032-4759-aa7f-b5042081f450';
const FILE = '1uDnRPWf1Dqx5b9L3TuhfvutyZpSaifz7YFNrRy_eBhw';
const DOC = '7TGywaNiUY';
function parseDayLabel(raw: string): { dateIso: string; label: string } | null {
const m = raw.trim().match(/^(\d{1,2})[\/.\-](\d{1,2})[\/.\-](\d{2,4})$/);
if (!m) return null;
let y = Number(m[3]);
const mo = Number(m[2]);
const d = Number(m[1]);
if (y < 100) y += 2000;
if (y < 2000 || y > 2100 || mo < 1 || mo > 12 || d < 1 || d > 31) return null;
return {
dateIso: `${y}-${String(mo).padStart(2, '0')}-${String(d).padStart(2, '0')}`,
label: `${d}/${mo}/${String(y).slice(2)}`,
};
}
function splitNotesByDay(text: string) {
const clean = text.replace(/^\uFEFF/, '').replace(/\r\n/g, '\n');
const dayRe =
/^(?:#{1,4}\s*)?(\d{1,2})[\/.\-](\d{1,2})[\/.\-](\d{2,4})\s*$/;
const lines = clean.split('\n');
const hits: { idx: number; label: string; dateIso: string }[] = [];
for (let i = 0; i < lines.length; i++) {
const m = lines[i].trim().match(dayRe);
if (!m) continue;
const parsed = parseDayLabel(`${m[1]}/${m[2]}/${m[3]}`);
if (!parsed) continue;
hits.push({ idx: i, ...parsed });
}
if (!hits.length) return [] as { dateIso: string; label: string; body: string }[];
const sections: { dateIso: string; label: string; body: string }[] = [];
for (let h = 0; h < hits.length; h++) {
const start = hits[h].idx + 1;
const end = h + 1 < hits.length ? hits[h + 1].idx : lines.length;
const body = lines
.slice(start, end)
.join('\n')
.replace(/^\n+/, '')
.replace(/\n{3,}/g, '\n\n')
.trim();
if (!body) continue;
sections.push({ dateIso: hits[h].dateIso, label: hits[h].label, body });
}
sections.sort((a, b) => b.dateIso.localeCompare(a.dateIso));
return sections;
}
async function exportViaDownload(fileId: string, mime: string) {
const token = await getValidAccessToken();
const url = `https://www.googleapis.com/drive/v3/files/${encodeURIComponent(fileId)}/export?mimeType=${encodeURIComponent(mime)}`;
const r = await fetch(url, {
headers: { Authorization: `Bearer ${token}` },
});
if (!r.ok) throw new Error(`${mime} ${r.status}: ${(await r.text()).slice(0, 200)}`);
return await r.text();
}
async function main() {
let plain = '';
try {
plain = await exportGoogleDocPlain(FILE);
console.log('plain via helper', plain.length);
} catch (e) {
console.log('plain helper fail', e instanceof Error ? e.message : e);
}
if (!plain) {
try {
plain = await exportViaDownload(FILE, 'text/plain');
console.log('plain via download', plain.length);
} catch (e) {
console.log('plain download fail', e instanceof Error ? e.message : e);
}
}
try {
const html = await exportGoogleDocHtml(FILE);
console.log('html ok', html.length);
} catch (e) {
console.log('html fail', e instanceof Error ? e.message : e);
}
if (!plain) throw new Error('no plain export');
console.log('--- sample ---\n', plain.slice(0, 2000));
const secs = splitNotesByDay(plain);
console.log('sections', secs.length, secs.slice(0, 8).map((s) => s.label));
const now = new Date().toISOString();
const rows = secs.slice(0, 120).map((sec) => {
const firstLine =
sec.body
.split('\n')
.map((l) => l.replace(/^[\s*•\-]+/, '').trim())
.find((l) => l.length > 8) || '';
return {
tenant_id: BPS,
source: 'superhuman' as const,
entry_type: 'meeting' as const,
external_id: `gdoc:${FILE}:${sec.dateIso}`,
title: `Seguimiento · ${sec.label}`,
summary: firstLine.slice(0, 220) || null,
body_md: `<pre>${sec.body
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')}</pre>`,
browser_link: `https://docs.google.com/document/d/${FILE}/edit`,
metrics: {},
occurred_at: `${sec.dateIso}T12:00:00.000Z`,
synced_at: now,
updated_at: now,
};
});
const n = await upsertSeguimientoRows(rows);
await patchSuperhumanIntegration(BPS, {
status: 'connected',
external_account_id: DOC,
label: '01.BLUE PARROT SCHOOL',
last_sync_at: now,
error_message: null,
meta: {
doc_name: '01.BLUE PARROT SCHOOL',
gdoc_id: FILE,
gdoc_days: n,
synced_at: now,
},
});
console.log('upserted day notes', n);
}
main().catch((e) => {
console.error(e);
process.exit(1);
});

View File

@@ -0,0 +1,59 @@
import { config } from 'dotenv';
import { resolve } from 'path';
config({ path: resolve(process.cwd(), '.env.local') });
import {
buildSeguimientoRowsForDoc,
upsertSeguimientoRows,
patchSuperhumanIntegration,
findSeguimientoGoogleDoc,
} from '../src/lib/superhuman-docs';
const BPS = '8bec977a-6032-4759-aa7f-b5042081f450';
const DOC = '7TGywaNiUY';
async function main() {
const hit = await findSeguimientoGoogleDoc([
'Seguimiento BPS',
'Seguimiento Semanal BPS',
'Seguimiento Blue Parrot School',
'Seguimiento Semanal Blue Parrot School',
]);
console.log('drive hit', hit);
const rows = await buildSeguimientoRowsForDoc({
tenantId: BPS,
tenantName: 'BPS',
docId: DOC,
maxEntries: 120,
exportBodies: true,
});
const gdoc = rows.filter((r) => r.external_id.startsWith('gdoc:'));
console.log('total', rows.length, 'gdoc days', gdoc.length);
console.log(
gdoc.slice(0, 5).map((r) => ({
title: r.title,
day: r.occurred_at,
len: (r.body_md || '').length,
}))
);
const n = await upsertSeguimientoRows(rows);
await patchSuperhumanIntegration(BPS, {
status: 'connected',
external_account_id: DOC,
label: '01.BLUE PARROT SCHOOL',
last_sync_at: new Date().toISOString(),
error_message: null,
meta: {
doc_name: '01.BLUE PARROT SCHOOL',
entries: n,
gdoc_days: gdoc.length,
synced_at: new Date().toISOString(),
},
});
console.log('upserted', n);
}
main().catch((e) => {
console.error(e);
process.exit(1);
});

View File

@@ -0,0 +1,343 @@
#!/usr/bin/env python3
"""Sync Clientify contacts → leads and deals → deals for one tenant.
Requires in .env.local:
CLIENTIFY_API_KEY (or CLIENTIFY_API_KEY_<SLUG>)
NEXT_PUBLIC_SUPABASE_URL
SUPABASE_SERVICE_ROLE_KEY
Usage:
python3 scripts/sync_clientify_tenant.py --tenant-id a7a6eb49-... --label agua-miia
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import urllib.error
import urllib.parse
import urllib.request
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[1]
CFY = "https://api.clientify.com/v1"
# Clientify deal status_desc → warehouse stage
STAGE_MAP = {
"won": "won",
"lost": "lost",
"open": "open",
}
def load_env() -> None:
for line in (ROOT / ".env.local").read_text().splitlines():
if "=" in line and not line.startswith("#"):
k, v = line.split("=", 1)
k = k.strip()
if k and k not in os.environ:
os.environ[k] = v.strip().strip('"').strip("'")
def env_key(label: str) -> str:
slug = label.upper().replace("-", "_").replace(" ", "_")
return (
os.environ.get(f"CLIENTIFY_API_KEY_{slug}")
or os.environ.get("CLIENTIFY_API_KEY")
or ""
)
def sb_headers() -> dict[str, str]:
key = os.environ["SUPABASE_SERVICE_ROLE_KEY"]
return {
"apikey": key,
"Authorization": f"Bearer {key}",
"Content-Type": "application/json",
}
def sb(method: str, path: str, body: Any = None, prefer: str = "return=minimal"):
url = os.environ["NEXT_PUBLIC_SUPABASE_URL"].rstrip("/") + path
headers = dict(sb_headers())
if prefer:
headers["Prefer"] = prefer
data = None if body is None else json.dumps(body).encode()
req = urllib.request.Request(url, data=data, headers=headers, method=method)
try:
with urllib.request.urlopen(req, timeout=120) as r:
raw = r.read().decode()
return r.status, (json.loads(raw) if raw.strip() else None)
except urllib.error.HTTPError as e:
return e.code, e.read().decode()[:500]
def cfy_get(token: str, path: str) -> dict:
req = urllib.request.Request(
CFY + path,
headers={"Authorization": f"Token {token}", "Accept": "application/json"},
)
with urllib.request.urlopen(req, timeout=60) as r:
return json.loads(r.read())
def cfy_paginate(token: str, path: str, page_size: int = 100) -> list[dict]:
out: list[dict] = []
# path may already have query
sep = "&" if "?" in path else "?"
url_path = f"{path}{sep}page_size={page_size}"
page = 1
while True:
q = f"{url_path}&page={page}" if "page=" not in url_path else url_path
# rebuild clean
base, _, query = path.partition("?")
params = urllib.parse.parse_qs(query)
params["page_size"] = [str(page_size)]
params["page"] = [str(page)]
q = base + "?" + urllib.parse.urlencode({k: v[0] for k, v in params.items()})
j = cfy_get(token, q)
batch = j.get("results") or []
out.extend(batch)
print(f" {path} page={page} +{len(batch)} total={len(out)}/{j.get('count')}", flush=True)
if not j.get("next") or not batch:
break
page += 1
return out
def first_email(c: dict) -> str | None:
emails = c.get("emails") or []
if isinstance(emails, list) and emails:
e = emails[0]
if isinstance(e, dict):
return (e.get("email") or e.get("value") or "").strip() or None
return str(e).strip() or None
return None
def first_phone(c: dict) -> str | None:
phones = c.get("phones") or []
if isinstance(phones, list) and phones:
p = phones[0]
if isinstance(p, dict):
return (p.get("phone") or p.get("value") or "").strip() or None
return str(p).strip() or None
return None
def contact_id_from_url(url: str | None) -> str | None:
if not url:
return None
# https://api.clientify.com/v1/contacts/12345/
parts = url.rstrip("/").split("/")
return parts[-1] if parts else None
def map_stage(deal: dict) -> str:
desc = (deal.get("status_desc") or "").strip().lower()
if desc in STAGE_MAP:
return STAGE_MAP[desc]
st = deal.get("status")
# Observed: 1 open-ish, 3 won, 4 lost (API filter unreliable)
if st == 3 or desc == "won":
return "won"
if st == 4 or desc == "lost":
return "lost"
return "open"
def parse_date(s: str | None) -> str | None:
if not s:
return None
# date-only or iso
try:
if "T" in s:
return datetime.fromisoformat(s.replace("Z", "+00:00")).astimezone(timezone.utc).isoformat()
return datetime.fromisoformat(s + "T12:00:00+00:00").astimezone(timezone.utc).isoformat()
except Exception:
return None
def main() -> int:
load_env()
ap = argparse.ArgumentParser()
ap.add_argument("--tenant-id", required=True)
ap.add_argument("--label", default="tenant")
ap.add_argument("--contacts-only", action="store_true")
ap.add_argument("--deals-only", action="store_true")
args = ap.parse_args()
token = env_key(args.label)
if not token:
print("missing CLIENTIFY_API_KEY", file=sys.stderr)
return 2
tid = args.tenant_id
print(f"Sync Clientify → warehouse tenant={tid} label={args.label}", flush=True)
do_contacts = not args.deals_only
do_deals = not args.contacts_only
contact_to_lead: dict[str, str] = {}
if do_contacts:
print("Fetching contacts…", flush=True)
contacts = cfy_paginate(token, "/contacts/")
# wipe previous clientify leads for tenant
st, _ = sb(
"DELETE",
f"/rest/v1/leads?tenant_id=eq.{tid}&crm=eq.clientify",
)
print(f"DELETE leads → {st}", flush=True)
rows = []
for c in contacts:
cid = str(c["id"])
lid = str(uuid.uuid5(uuid.NAMESPACE_URL, f"clientify-lead:{tid}:{cid}"))
contact_to_lead[cid] = lid
source = (
(c.get("contact_source") or c.get("channel") or c.get("medium") or "clientify")
)
if isinstance(source, str):
source = source.strip().lower()[:80] or "clientify"
else:
source = "clientify"
status = (c.get("status") or "new")
if isinstance(status, str):
status = status.strip().lower()[:40] or "new"
else:
status = "new"
rows.append(
{
"id": lid,
"tenant_id": tid,
"first_name": (
" ".join(
x
for x in [(c.get("first_name") or "").strip(), (c.get("last_name") or "").strip()]
if x
)
or None
),
"email": first_email(c),
"phone": first_phone(c),
"source": source,
"medium": (c.get("medium") or None),
"crm": "clientify",
"crm_contact_id": cid,
"status": status,
"created_at": parse_date(c.get("created"))
or datetime.now(timezone.utc).isoformat(),
}
)
ok = 0
for i in range(0, len(rows), 200):
chunk = rows[i : i + 200]
st, err = sb("POST", "/rest/v1/leads", chunk)
if st not in (200, 201):
print("FAIL leads", st, err, flush=True)
return 1
ok += len(chunk)
print(f" INSERT leads {ok}/{len(rows)}", flush=True)
print(f"OK contacts→leads {ok}", flush=True)
if do_deals:
if not contact_to_lead and do_contacts is False:
# load existing map
st, data = sb(
"GET",
f"/rest/v1/leads?tenant_id=eq.{tid}&crm=eq.clientify&select=id,crm_contact_id&limit=20000",
prefer="",
)
if st == 200 and isinstance(data, list):
for r in data:
if r.get("crm_contact_id"):
contact_to_lead[str(r["crm_contact_id"])] = r["id"]
print("Fetching deals…", flush=True)
deals = cfy_paginate(token, "/deals/")
st, _ = sb(
"DELETE",
f"/rest/v1/deals?tenant_id=eq.{tid}&crm_deal_id=like.clientify:*",
)
print(f"DELETE deals → {st}", flush=True)
rows = []
by_stage = {"won": 0, "lost": 0, "open": 0}
for d in deals:
did = str(d["id"])
stage = map_stage(d)
by_stage[stage] = by_stage.get(stage, 0) + 1
cid = contact_id_from_url(d.get("contact"))
lead_id = contact_to_lead.get(cid) if cid else None
amount = float(d.get("amount") or d.get("amount_user") or 0)
closed = parse_date(d.get("actual_closed_date"))
pipe_url = str(d.get("pipeline") or "")
pipe_id = ""
if "/pipelines/" in pipe_url:
pipe_id = pipe_url.rstrip("/").split("/")[-1]
raw_src = (
d.get("deal_source")
or d.get("contact_source")
or ""
)
if isinstance(raw_src, str):
raw_src = raw_src.strip()[:80]
else:
raw_src = ""
tags = d.get("tags") or []
tag_blob = " ".join(str(t) for t in tags).lower() if isinstance(tags, list) else ""
name = (d.get("name") or "")[:200]
if not raw_src:
if any(x in tag_blob for x in ("facebook", "instagram", "meta", " fb ")):
raw_src = "meta"
elif "google" in tag_blob:
raw_src = "google"
elif "woocommerce" in tag_blob or name.lower().startswith("order "):
raw_src = "ecommerce"
row = {
"id": str(uuid.uuid5(uuid.NAMESPACE_URL, f"clientify-deal:{tid}:{did}")),
"tenant_id": tid,
"lead_id": lead_id,
"crm_deal_id": f"clientify:{did}",
"stage": stage,
"amount": amount,
"won_at": closed if stage == "won" else None,
"lost_at": closed if stage == "lost" else None,
"pipeline_id": pipe_id or None,
"pipeline_name": (d.get("pipeline_desc") or None),
"source": raw_src.lower() if raw_src else None,
"name": name or None,
}
rows.append(row)
ok = 0
for i in range(0, len(rows), 200):
chunk = rows[i : i + 200]
st, err = sb("POST", "/rest/v1/deals", chunk)
if st not in (200, 201):
print("FAIL deals", st, err, flush=True)
return 1
ok += len(chunk)
print(f" INSERT deals {ok}/{len(rows)}", flush=True)
won_amt = sum(r["amount"] for r in rows if r["stage"] == "won")
print(f"OK deals {ok} by_stage={by_stage} won_amount={won_amt:.2f}", flush=True)
# touch integration
now = datetime.now(timezone.utc).isoformat()
sb(
"PATCH",
f"/rest/v1/integrations?tenant_id=eq.{tid}&provider=eq.clientify",
{"status": "connected", "last_sync_at": now, "error_message": None},
)
print("DONE", flush=True)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,5 @@
-- Store fractional Google Ads conversions so period totals match Ads Manager
-- (Math.round of the exact sum) for any date range.
ALTER TABLE public.ad_daily
ALTER COLUMN platform_leads TYPE numeric(14, 6)
USING platform_leads::numeric(14, 6);

View File

@@ -0,0 +1,18 @@
-- Meta: clics en el enlace (Ads Manager) aparte de clicks = clics todos.
ALTER TABLE public.ad_daily
ADD COLUMN IF NOT EXISTS link_clicks numeric(14, 6) NOT NULL DEFAULT 0;
-- Backfill from legacy Meta marker stored in ad_name (`__lc:N`).
UPDATE public.ad_daily
SET link_clicks = (regexp_match(ad_name, '^__lc:([0-9.]+)$'))[1]::numeric
WHERE platform = 'meta'
AND ad_name ~ '^__lc:'
AND (link_clicks IS NULL OR link_clicks = 0);
-- Google: link clicks ≈ all clicks at campaign level.
UPDATE public.ad_daily
SET link_clicks = clicks
WHERE platform = 'google'
AND (link_clicks IS NULL OR link_clicks = 0)
AND clicks IS NOT NULL
AND clicks > 0;

16
sql/agency_control.sql Normal file
View File

@@ -0,0 +1,16 @@
-- Agency control: follow/archive clients + enable/disable channels
-- Idempotent.
alter table public.tenants
add column if not exists agency_followed boolean not null default true;
alter table public.tenants
add column if not exists agency_notes text;
alter table public.integrations
add column if not exists enabled boolean not null default true;
comment on column public.tenants.agency_followed is
'Agency portfolio: false = archived / not actively managed in Centro de Mando';
comment on column public.integrations.enabled is
'Agency wants this channel for the client (false = deliberately off)';

116
sql/agency_portfolio.sql Normal file
View File

@@ -0,0 +1,116 @@
-- Agency portfolio: integrations + alerts per tenant
-- Idempotent. Run as postgres / service role.
create table if not exists public.integrations (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
provider text not null
check (provider in (
'meta_ads',
'google_ads',
'ga4',
'search_console',
'tag_manager',
'clientify',
'klaviyo',
'mailchimp'
)),
status text not null default 'disconnected'
check (status in ('disconnected', 'pending', 'connected', 'error')),
external_account_id text,
label text,
last_sync_at timestamptz,
error_message text,
meta jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (tenant_id, provider)
);
create index if not exists integrations_tenant_idx
on public.integrations (tenant_id, provider);
alter table public.integrations enable row level security;
drop policy if exists integrations_tenant_select on public.integrations;
drop policy if exists integrations_tenant_write on public.integrations;
-- Clients see only their tenant; agency uses service role for cross-tenant.
create policy integrations_tenant_select on public.integrations
for select using (
tenant_id::text = coalesce(
auth.jwt() -> 'app_metadata' ->> 'tenant_id',
auth.jwt() -> 'user_metadata' ->> 'tenant_id'
)
);
create policy integrations_tenant_write on public.integrations
for all using (
tenant_id::text = coalesce(
auth.jwt() -> 'app_metadata' ->> 'tenant_id',
auth.jwt() -> 'user_metadata' ->> 'tenant_id'
)
)
with check (
tenant_id::text = coalesce(
auth.jwt() -> 'app_metadata' ->> 'tenant_id',
auth.jwt() -> 'user_metadata' ->> 'tenant_id'
)
);
create table if not exists public.agency_alerts (
id uuid primary key default gen_random_uuid(),
tenant_id uuid references public.tenants(id) on delete cascade,
severity text not null default 'info'
check (severity in ('info', 'warning', 'critical')),
source text not null default 'system',
title text not null,
body text,
href text,
read_at timestamptz,
created_at timestamptz not null default now()
);
create index if not exists agency_alerts_unread_idx
on public.agency_alerts (created_at desc)
where read_at is null;
alter table public.agency_alerts enable row level security;
drop policy if exists agency_alerts_tenant_select on public.agency_alerts;
create policy agency_alerts_tenant_select on public.agency_alerts
for select using (
tenant_id is null
or tenant_id::text = coalesce(
auth.jwt() -> 'app_metadata' ->> 'tenant_id',
auth.jwt() -> 'user_metadata' ->> 'tenant_id'
)
);
-- Seed disconnected integration slots for every existing tenant
insert into public.integrations (tenant_id, provider, status, label)
select t.id, p.provider, 'disconnected', p.label
from public.tenants t
cross join (
values
('meta_ads', 'Meta Ads'),
('google_ads', 'Google Ads (MCC)'),
('ga4', 'Google Analytics 4'),
('search_console', 'Search Console'),
('tag_manager', 'Tag Manager'),
('clientify', 'Clientify'),
('klaviyo', 'Klaviyo'),
('mailchimp', 'Mailchimp')
) as p(provider, label)
on conflict (tenant_id, provider) do nothing;
-- Bootstrap alert so the UI isn't empty on day one
insert into public.agency_alerts (tenant_id, severity, source, title, body, href)
select null, 'info', 'system',
'Portafolio agencia activo',
'Selector de cliente + fichas de integración listas. Conecta Meta/MCC/GA4 por tenant.',
'/dashboard/agencia'
where not exists (
select 1 from public.agency_alerts where title = 'Portafolio agencia activo'
);

38
sql/agent_runs_rls.sql Normal file
View File

@@ -0,0 +1,38 @@
-- Idempotent: enable RLS + tenant policies on agent_runs
-- Safe to re-run.
alter table public.agent_runs enable row level security;
drop policy if exists agent_runs_tenant_select on public.agent_runs;
drop policy if exists agent_runs_tenant_insert on public.agent_runs;
drop policy if exists agent_runs_tenant_update on public.agent_runs;
create policy agent_runs_tenant_select on public.agent_runs
for select using (
tenant_id::text = coalesce(
auth.jwt() -> 'app_metadata' ->> 'tenant_id',
auth.jwt() -> 'user_metadata' ->> 'tenant_id'
)
);
create policy agent_runs_tenant_insert on public.agent_runs
for insert with check (
tenant_id::text = coalesce(
auth.jwt() -> 'app_metadata' ->> 'tenant_id',
auth.jwt() -> 'user_metadata' ->> 'tenant_id'
)
);
create policy agent_runs_tenant_update on public.agent_runs
for update using (
tenant_id::text = coalesce(
auth.jwt() -> 'app_metadata' ->> 'tenant_id',
auth.jwt() -> 'user_metadata' ->> 'tenant_id'
)
);
-- Verify
select c.relname, c.relrowsecurity
from pg_class c
join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'public' and c.relname = 'agent_runs';

64
sql/asset_files.sql Normal file
View File

@@ -0,0 +1,64 @@
-- File assets library (validated uploads per tenant)
-- Idempotent.
create table if not exists public.asset_files (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
title text not null,
folder text not null default '',
file_name text not null,
mime_type text,
bytes bigint,
storage_path text not null,
sha256 text,
status text not null default 'approved'
check (status in ('approved', 'archived')),
created_by uuid references auth.users(id),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create index if not exists asset_files_tenant_folder_idx
on public.asset_files (tenant_id, folder, created_at desc);
alter table public.asset_files enable row level security;
drop policy if exists asset_files_tenant_select on public.asset_files;
drop policy if exists asset_files_tenant_insert on public.asset_files;
drop policy if exists asset_files_tenant_update on public.asset_files;
drop policy if exists asset_files_tenant_delete on public.asset_files;
create policy asset_files_tenant_select on public.asset_files
for select using (
tenant_id::text = coalesce(
auth.jwt() -> 'app_metadata' ->> 'tenant_id',
auth.jwt() -> 'user_metadata' ->> 'tenant_id'
)
);
create policy asset_files_tenant_insert on public.asset_files
for insert with check (
tenant_id::text = coalesce(
auth.jwt() -> 'app_metadata' ->> 'tenant_id',
auth.jwt() -> 'user_metadata' ->> 'tenant_id'
)
);
create policy asset_files_tenant_update on public.asset_files
for update using (
tenant_id::text = coalesce(
auth.jwt() -> 'app_metadata' ->> 'tenant_id',
auth.jwt() -> 'user_metadata' ->> 'tenant_id'
)
);
create policy asset_files_tenant_delete on public.asset_files
for delete using (
tenant_id::text = coalesce(
auth.jwt() -> 'app_metadata' ->> 'tenant_id',
auth.jwt() -> 'user_metadata' ->> 'tenant_id'
)
);
-- Private bucket (created via API if missing). Policies for storage.objects:
-- path layout: {tenant_id}/{optional/folder/}{uuid}_{filename}

31
sql/cms_integrations.sql Normal file
View File

@@ -0,0 +1,31 @@
-- CMS / ecommerce connection slots per tenant
-- Idempotent.
alter table public.integrations drop constraint if exists integrations_provider_check;
alter table public.integrations
add constraint integrations_provider_check
check (provider in (
'meta_ads',
'google_ads',
'ga4',
'search_console',
'tag_manager',
'clientify',
'klaviyo',
'mailchimp',
'google_drive',
'wordpress',
'prestashop',
'shopify'
));
insert into public.integrations (tenant_id, provider, status, label)
select t.id, p.provider, 'disconnected', p.label
from public.tenants t
cross join (
values
('wordpress', 'WordPress'),
('prestashop', 'PrestaShop'),
('shopify', 'Shopify')
) as p(provider, label)
on conflict (tenant_id, provider) do nothing;

180
sql/content_collab.sql Normal file
View File

@@ -0,0 +1,180 @@
-- Dual approval (agency + client) + threaded comments for calendar posts & assets
-- Idempotent. Private portal only (no public publish).
-- ---------------------------------------------------------------------------
-- Calendar posts (normalized; private per tenant)
-- ---------------------------------------------------------------------------
create table if not exists public.calendar_posts (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
external_key text,
month text,
date date not null,
time_slot text,
platform text not null
check (platform in (
'instagram', 'facebook', 'linkedin', 'tiktok', 'youtube', 'x', 'other'
)),
format text,
pillar text,
title text not null,
hook text,
body text,
cta text,
hashtags text,
objective_metric text,
status text not null default 'draft'
check (status in ('draft', 'in_review', 'scheduled', 'published', 'archived')),
agency_approved_at timestamptz,
agency_approved_by uuid references auth.users(id),
client_approved_at timestamptz,
client_approved_by uuid references auth.users(id),
meta jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (tenant_id, external_key)
);
create index if not exists calendar_posts_tenant_month_idx
on public.calendar_posts (tenant_id, month, date);
create index if not exists calendar_posts_tenant_platform_idx
on public.calendar_posts (tenant_id, platform, date);
alter table public.calendar_posts enable row level security;
drop policy if exists calendar_posts_tenant_select on public.calendar_posts;
drop policy if exists calendar_posts_tenant_write on public.calendar_posts;
create policy calendar_posts_tenant_select on public.calendar_posts
for select using (
tenant_id::text = coalesce(
auth.jwt() -> 'app_metadata' ->> 'tenant_id',
auth.jwt() -> 'user_metadata' ->> 'tenant_id'
)
);
create policy calendar_posts_tenant_write on public.calendar_posts
for all using (
tenant_id::text = coalesce(
auth.jwt() -> 'app_metadata' ->> 'tenant_id',
auth.jwt() -> 'user_metadata' ->> 'tenant_id'
)
)
with check (
tenant_id::text = coalesce(
auth.jwt() -> 'app_metadata' ->> 'tenant_id',
auth.jwt() -> 'user_metadata' ->> 'tenant_id'
)
);
-- ---------------------------------------------------------------------------
-- Shared comments (calendar_post | asset)
-- ---------------------------------------------------------------------------
create table if not exists public.content_comments (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
resource_type text not null check (resource_type in ('calendar_post', 'asset')),
resource_id text not null,
body text not null,
author_id uuid references auth.users(id),
author_email text,
author_party text not null check (author_party in ('agency', 'client')),
created_at timestamptz not null default now()
);
create index if not exists content_comments_resource_idx
on public.content_comments (tenant_id, resource_type, resource_id, created_at);
alter table public.content_comments enable row level security;
drop policy if exists content_comments_tenant_select on public.content_comments;
drop policy if exists content_comments_tenant_insert on public.content_comments;
create policy content_comments_tenant_select on public.content_comments
for select using (
tenant_id::text = coalesce(
auth.jwt() -> 'app_metadata' ->> 'tenant_id',
auth.jwt() -> 'user_metadata' ->> 'tenant_id'
)
);
create policy content_comments_tenant_insert on public.content_comments
for insert with check (
tenant_id::text = coalesce(
auth.jwt() -> 'app_metadata' ->> 'tenant_id',
auth.jwt() -> 'user_metadata' ->> 'tenant_id'
)
);
-- ---------------------------------------------------------------------------
-- Dual approval events (calendar_post | asset)
-- ---------------------------------------------------------------------------
create table if not exists public.content_approvals (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
resource_type text not null check (resource_type in ('calendar_post', 'asset')),
resource_id text not null,
party text not null check (party in ('agency', 'client')),
approved boolean not null default true,
note text,
actor_id uuid references auth.users(id),
actor_email text,
created_at timestamptz not null default now(),
unique (tenant_id, resource_type, resource_id, party)
);
alter table public.content_approvals enable row level security;
drop policy if exists content_approvals_tenant_select on public.content_approvals;
drop policy if exists content_approvals_tenant_write on public.content_approvals;
create policy content_approvals_tenant_select on public.content_approvals
for select using (
tenant_id::text = coalesce(
auth.jwt() -> 'app_metadata' ->> 'tenant_id',
auth.jwt() -> 'user_metadata' ->> 'tenant_id'
)
);
create policy content_approvals_tenant_write on public.content_approvals
for all using (
tenant_id::text = coalesce(
auth.jwt() -> 'app_metadata' ->> 'tenant_id',
auth.jwt() -> 'user_metadata' ->> 'tenant_id'
)
)
with check (
tenant_id::text = coalesce(
auth.jwt() -> 'app_metadata' ->> 'tenant_id',
auth.jwt() -> 'user_metadata' ->> 'tenant_id'
)
);
-- ---------------------------------------------------------------------------
-- Assets: dual validation columns
-- ---------------------------------------------------------------------------
alter table public.asset_files
add column if not exists review_status text not null default 'in_review';
alter table public.asset_files
drop constraint if exists asset_files_review_status_check;
alter table public.asset_files
add constraint asset_files_review_status_check
check (review_status in ('draft', 'in_review', 'validated', 'archived'));
alter table public.asset_files
add column if not exists agency_approved_at timestamptz;
alter table public.asset_files
add column if not exists agency_approved_by uuid references auth.users(id);
alter table public.asset_files
add column if not exists client_approved_at timestamptz;
alter table public.asset_files
add column if not exists client_approved_by uuid references auth.users(id);
comment on table public.calendar_posts is
'Private RRSS calendar per tenant. Dual agency/client approval. No public publish.';
comment on table public.content_comments is
'Comments on calendar posts or assets (agency + client).';
comment on table public.content_approvals is
'Dual-check approvals: agency and client must both approve for full OK.';

View File

@@ -0,0 +1,11 @@
-- Allow tenant-scoped delete on creatives (clients); agency uses service role.
alter table public.creatives enable row level security;
drop policy if exists creatives_tenant_delete on public.creatives;
create policy creatives_tenant_delete on public.creatives
for delete using (
tenant_id::text = coalesce(
auth.jwt() -> 'app_metadata' ->> 'tenant_id',
auth.jwt() -> 'user_metadata' ->> 'tenant_id'
)
);

89
sql/creatives_preview.sql Normal file
View File

@@ -0,0 +1,89 @@
-- Preview fields for creatives pipeline (ads + RRSS placement mocks)
-- Idempotent.
alter table public.creatives
add column if not exists channel text;
alter table public.creatives
add column if not exists placement text;
alter table public.creatives
add column if not exists format text;
alter table public.creatives
add column if not exists headline text;
alter table public.creatives
add column if not exists primary_text text;
alter table public.creatives
add column if not exists description text;
alter table public.creatives
add column if not exists cta text;
alter table public.creatives
add column if not exists media_url text;
alter table public.creatives
add column if not exists brand_name text;
alter table public.creatives
add column if not exists landing_url text;
alter table public.creatives
add column if not exists meta jsonb not null default '{}'::jsonb;
-- Soft checks (nullable for legacy rows)
do $$
begin
if not exists (
select 1 from pg_constraint where conname = 'creatives_channel_check'
) then
alter table public.creatives
add constraint creatives_channel_check
check (
channel is null
or channel in (
'meta_ads',
'google_ads',
'instagram',
'facebook',
'linkedin',
'tiktok',
'stories',
'other'
)
);
end if;
end $$;
do $$
begin
if not exists (
select 1 from pg_constraint where conname = 'creatives_format_check'
) then
alter table public.creatives
add constraint creatives_format_check
check (
format is null
or format in (
'image',
'video',
'carousel',
'reel',
'story',
'rsa',
'pmax',
'other'
)
);
end if;
end $$;
comment on column public.creatives.channel is
'Surface: meta_ads | google_ads | instagram | facebook | linkedin | stories…';
comment on column public.creatives.placement is
'Placement mock: feed | stories | reels | search | display…';
comment on column public.creatives.media_url is
'Public or signed URL for creative media preview';

View File

@@ -0,0 +1,35 @@
-- Expand creatives.format for Google Performance Max asset ratios + upload fields
-- Idempotent.
alter table public.creatives drop constraint if exists creatives_format_check;
alter table public.creatives
add constraint creatives_format_check
check (
format is null
or format in (
'image',
'video',
'carousel',
'reel',
'story',
'rsa',
'pmax',
'pmax_landscape',
'pmax_square',
'pmax_portrait',
'pmax_vertical',
'pmax_logo',
'youtube',
'other'
)
);
alter table public.creatives
add column if not exists storage_path text;
alter table public.creatives
add column if not exists mime_type text;
comment on column public.creatives.storage_path is
'Path in tenant-assets bucket for uploaded creative media';

4
sql/deals_pipeline.sql Normal file
View File

@@ -0,0 +1,4 @@
-- Pipeline on deals so KPI ventas can match Clientify board filters (e.g. Online).
ALTER TABLE public.deals
ADD COLUMN IF NOT EXISTS pipeline_id text,
ADD COLUMN IF NOT EXISTS pipeline_name text;

4
sql/deals_source.sql Normal file
View File

@@ -0,0 +1,4 @@
-- Attribution source on deals (Clientify deal_source / contact / lead).
ALTER TABLE public.deals
ADD COLUMN IF NOT EXISTS source text,
ADD COLUMN IF NOT EXISTS name text;

42
sql/google_drive.sql Normal file
View File

@@ -0,0 +1,42 @@
-- Google Drive agency OAuth + per-tenant folder links
-- Idempotent.
-- Agency-wide OAuth tokens (service role only; no client policies)
create table if not exists public.agency_oauth (
provider text primary key,
account_email text,
access_token text not null,
refresh_token text,
expires_at timestamptz,
scopes text,
meta jsonb not null default '{}'::jsonb,
updated_at timestamptz not null default now()
);
alter table public.agency_oauth enable row level security;
-- Widen integrations.provider check to include google_drive
alter table public.integrations drop constraint if exists integrations_provider_check;
alter table public.integrations
add constraint integrations_provider_check
check (provider in (
'meta_ads',
'google_ads',
'ga4',
'search_console',
'tag_manager',
'clientify',
'klaviyo',
'mailchimp',
'google_drive'
));
-- Seed google_drive slots for every tenant
insert into public.integrations (tenant_id, provider, status, label)
select t.id, 'google_drive', 'disconnected', 'Google Drive'
from public.tenants t
on conflict (tenant_id, provider) do nothing;
-- Optional Drive file id on portal assets (mirror)
alter table public.asset_files
add column if not exists drive_file_id text;

View File

@@ -0,0 +1,22 @@
-- CRM date correctness: preserve lead birth vs status change timing.
-- Run in Supabase SQL editor (service role / dashboard).
alter table public.leads
add column if not exists updated_at timestamptz;
alter table public.leads
add column if not exists status_changed_at timestamptz;
comment on column public.leads.created_at is
'When the contact entered Clientify / first seen (alta).';
comment on column public.leads.updated_at is
'Last webhook/sync touch.';
comment on column public.leads.status_changed_at is
'When status last changed (e.g. → cliente). Use this for “ventas en el periodo”.';
-- Backfill: unknown status timing → treat as created_at (best effort).
update public.leads
set
updated_at = coalesce(updated_at, created_at, now()),
status_changed_at = coalesce(status_changed_at, created_at, now())
where updated_at is null or status_changed_at is null;

75
sql/organic_geo.sql Normal file
View File

@@ -0,0 +1,75 @@
-- Organic SEO (GA4/GSC) + GEO (AI visibility) warehouse
-- Idempotent.
create table if not exists public.organic_daily (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
day date not null,
gsc_clicks numeric not null default 0,
gsc_impressions numeric not null default 0,
gsc_ctr numeric,
gsc_avg_position numeric,
ga4_sessions numeric not null default 0,
ga4_users numeric not null default 0,
ga4_organic_sessions numeric not null default 0,
ga4_conversions numeric not null default 0,
source text not null default 'manual'
check (source in ('manual', 'sync', 'import')),
meta jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now(),
unique (tenant_id, day)
);
create index if not exists organic_daily_tenant_day_idx
on public.organic_daily (tenant_id, day desc);
create table if not exists public.geo_snapshots (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
label text not null default 'Medición GEO',
period_start date,
period_end date,
-- Share of Model: % de primeras respuestas / menciones de la marca
share_of_model numeric,
-- Engines: { chatgpt, gemini, ai_overviews } visibility %
engines jsonb not null default '{}'::jsonb,
-- Official brand site citation share among sources
official_citation_pct numeric,
-- Sentiment -1..1 or 0..100 (store 0..100)
sentiment_score numeric,
-- Competitor share of model rows: [{ brand, share, rank }]
competitors jsonb not null default '[]'::jsonb,
-- Intent / category visibility: [{ category, visibility, rank }]
categories jsonb not null default '[]'::jsonb,
-- Top sources feeding AI answers
top_sources jsonb not null default '[]'::jsonb,
prompts_analyzed integer,
responses_analyzed integer,
study_url text,
notes text,
created_at timestamptz not null default now()
);
create index if not exists geo_snapshots_tenant_created_idx
on public.geo_snapshots (tenant_id, created_at desc);
alter table public.organic_daily enable row level security;
alter table public.geo_snapshots enable row level security;
drop policy if exists organic_daily_tenant_select on public.organic_daily;
create policy organic_daily_tenant_select on public.organic_daily
for select using (
tenant_id::text = coalesce(
auth.jwt() -> 'app_metadata' ->> 'tenant_id',
auth.jwt() -> 'user_metadata' ->> 'tenant_id'
)
);
drop policy if exists geo_snapshots_tenant_select on public.geo_snapshots;
create policy geo_snapshots_tenant_select on public.geo_snapshots
for select using (
tenant_id::text = coalesce(
auth.jwt() -> 'app_metadata' ->> 'tenant_id',
auth.jwt() -> 'user_metadata' ->> 'tenant_id'
)
);

87
sql/seguimiento.sql Normal file
View File

@@ -0,0 +1,87 @@
-- Seguimiento: Superhuman Docs timeline + integration slot
-- Idempotent. Never stores passwords.
alter table public.integrations drop constraint if exists integrations_provider_check;
alter table public.integrations
add constraint integrations_provider_check
check (provider in (
'meta_ads',
'google_ads',
'ga4',
'search_console',
'tag_manager',
'clientify',
'klaviyo',
'mailchimp',
'google_drive',
'wordpress',
'prestashop',
'shopify',
'instagram',
'facebook',
'tiktok',
'linkedin',
'superhuman_docs'
));
insert into public.integrations (tenant_id, provider, status, label)
select t.id, 'superhuman_docs', 'disconnected', 'Superhuman Docs'
from public.tenants t
on conflict (tenant_id, provider) do nothing;
create table if not exists public.seguimiento_entries (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
source text not null default 'superhuman'
check (source in ('superhuman', 'manual')),
entry_type text not null default 'topic'
check (entry_type in ('meeting', 'topic', 'kpis', 'note')),
external_id text not null,
title text not null,
summary text,
body_md text,
browser_link text,
metrics jsonb not null default '{}'::jsonb,
occurred_at timestamptz,
synced_at timestamptz not null default now(),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (tenant_id, source, external_id)
);
create index if not exists seguimiento_entries_tenant_occurred_idx
on public.seguimiento_entries (tenant_id, occurred_at desc nulls last);
create index if not exists seguimiento_entries_tenant_synced_idx
on public.seguimiento_entries (tenant_id, synced_at desc);
alter table public.seguimiento_entries enable row level security;
drop policy if exists seguimiento_entries_tenant_select on public.seguimiento_entries;
drop policy if exists seguimiento_entries_tenant_write on public.seguimiento_entries;
create policy seguimiento_entries_tenant_select on public.seguimiento_entries
for select using (
tenant_id::text = coalesce(
auth.jwt() -> 'app_metadata' ->> 'tenant_id',
auth.jwt() -> 'user_metadata' ->> 'tenant_id'
)
);
-- Clients read-only via RLS; agency writes with service role.
create policy seguimiento_entries_tenant_write on public.seguimiento_entries
for all using (
tenant_id::text = coalesce(
auth.jwt() -> 'app_metadata' ->> 'tenant_id',
auth.jwt() -> 'user_metadata' ->> 'tenant_id'
)
)
with check (
tenant_id::text = coalesce(
auth.jwt() -> 'app_metadata' ->> 'tenant_id',
auth.jwt() -> 'user_metadata' ->> 'tenant_id'
)
);
comment on table public.seguimiento_entries is
'Client-facing seguimiento timeline (Superhuman Docs). No passwords.';

31
sql/seo_audits.sql Normal file
View File

@@ -0,0 +1,31 @@
-- SEO site audit history (technical checks + score)
-- Idempotent.
create table if not exists public.seo_audits (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
site_url text not null,
score numeric not null default 0,
grade text not null default 'F',
warning_count integer not null default 0,
critical_count integer not null default 0,
checks jsonb not null default '[]'::jsonb,
summary text,
created_at timestamptz not null default now()
);
create index if not exists seo_audits_tenant_created_idx
on public.seo_audits (tenant_id, created_at desc);
alter table public.seo_audits enable row level security;
drop policy if exists seo_audits_tenant_select on public.seo_audits;
create policy seo_audits_tenant_select on public.seo_audits
for select using (
tenant_id::text = coalesce(
auth.jwt() -> 'app_metadata' ->> 'tenant_id',
auth.jwt() -> 'user_metadata' ->> 'tenant_id'
)
);
-- Agency writes via service role (bypasses RLS).

View File

@@ -0,0 +1,36 @@
-- Organic social connection slots per tenant
-- Idempotent. (Distinct from meta_ads / paid.)
alter table public.integrations drop constraint if exists integrations_provider_check;
alter table public.integrations
add constraint integrations_provider_check
check (provider in (
'meta_ads',
'google_ads',
'ga4',
'search_console',
'tag_manager',
'clientify',
'klaviyo',
'mailchimp',
'google_drive',
'wordpress',
'prestashop',
'shopify',
'instagram',
'facebook',
'tiktok',
'linkedin'
));
insert into public.integrations (tenant_id, provider, status, label)
select t.id, p.provider, 'disconnected', p.label
from public.tenants t
cross join (
values
('instagram', 'Instagram'),
('facebook', 'Facebook Page'),
('tiktok', 'TikTok'),
('linkedin', 'LinkedIn')
) as p(provider, label)
on conflict (tenant_id, provider) do nothing;

13
sql/targets_budget.sql Normal file
View File

@@ -0,0 +1,13 @@
-- Monthly objectives: budget (ppto) + consecution targets
-- Idempotent.
alter table public.targets
add column if not exists spend_target numeric;
alter table public.targets
add column if not exists spend_actual numeric;
comment on column public.targets.spend_target is
'Presupuesto ads objetivo del mes (ppto)';
comment on column public.targets.spend_actual is
'Gasto ads real del mes (opcional; si null se calcula de ad_daily)';

13
sql/tenant_ficha.sql Normal file
View File

@@ -0,0 +1,13 @@
-- Per-tenant ficha: logo + dashboard module settings
-- Idempotent.
alter table public.tenants
add column if not exists logo_url text;
alter table public.tenants
add column if not exists settings jsonb not null default '{}'::jsonb;
comment on column public.tenants.logo_url is
'Client logo for portal shell (URL or storage path under tenant-assets).';
comment on column public.tenants.settings is
'JSON: { modules: { kpis, crm, seguimiento, estrategia, calendario, ad-tracker, activos, creatives, asistente }, branding: {} }';

View File

@@ -0,0 +1,214 @@
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 { AdDailyRow, linkClicksOf } from '@/lib/metrics';
import { normalizePlatform } from '@/lib/ad-tracker-range';
import {
AdCreativeCard,
buildMetaWarehouseCards,
fetchMetaAccountClickSplit,
fetchMetaCreativeCards,
} from '@/lib/ad-creatives-meta';
import {
buildGoogleCardsFromDaily,
fetchGoogleCreativeCards,
} from '@/lib/ad-creatives-google';
export const dynamic = 'force-dynamic';
function metaClickSplitFromDaily(
daily: AdDailyRow[],
since: string,
until: string
): { clicks: number; linkClicks: number; spend: number } {
let clicks = 0;
let linkClicks = 0;
let spend = 0;
for (const r of daily) {
if (normalizePlatform(r.platform) !== 'meta') continue;
const d = (r.date || '').slice(0, 10);
if (d < since || d > until) continue;
clicks += Number(r.clicks || 0) || 0;
linkClicks += linkClicksOf(r);
spend += Number(r.spend || 0) || 0;
}
return {
clicks,
linkClicks: linkClicks || clicks,
spend: Math.round(spend * 100) / 100,
};
}
/**
* GET /api/ad-tracker/creatives?since=&until=&platform=meta|google|all&mode=fast|live
*
* mode=fast → warehouse only (instant, same metrics as Ads sync)
* mode=live → enrich with Meta/Google creative assets (slower)
*/
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 });
}
const ctx = await resolveActiveTenant(user);
if (!ctx.tenantId || ctx.portfolioWide) {
return NextResponse.json(
{ error: 'select_tenant', hint: 'Elige un cliente en Contexto' },
{ status: 400 }
);
}
const since = req.nextUrl.searchParams.get('since') || '';
const until = req.nextUrl.searchParams.get('until') || '';
const platform = (req.nextUrl.searchParams.get('platform') || 'all').toLowerCase();
const mode = (req.nextUrl.searchParams.get('mode') || 'live').toLowerCase();
if (!/^\d{4}-\d{2}-\d{2}$/.test(since) || !/^\d{4}-\d{2}-\d{2}$/.test(until)) {
return NextResponse.json(
{ error: 'bad_range', hint: 'since/until YYYY-MM-DD' },
{ status: 400 }
);
}
const agency = isAgencyRole(userRole(user));
let daily: AdDailyRow[] = [];
const selectWithLc =
'date,platform,campaign_name,ad_id,ad_name,impressions,clicks,link_clicks,spend,platform_leads';
const selectNoLc =
'date,platform,campaign_name,ad_id,ad_name,impressions,clicks,spend,platform_leads';
if (agency && ctx.viaView) {
const tid = encodeURIComponent(ctx.tenantId);
let d = await restGet<AdDailyRow[]>(
`/rest/v1/ad_daily?tenant_id=eq.${tid}&date=gte.${since}&date=lte.${until}&select=${selectWithLc}&order=date.desc&limit=5000`
);
if (!d.ok && /link_clicks/i.test(String(d.error || ''))) {
d = await restGet<AdDailyRow[]>(
`/rest/v1/ad_daily?tenant_id=eq.${tid}&date=gte.${since}&date=lte.${until}&select=${selectNoLc}&order=date.desc&limit=5000`
);
}
daily = d.data || [];
} else {
let q = await supabase
.from('ad_daily')
.select(selectWithLc)
.gte('date', since)
.lte('date', until)
.order('date', { ascending: false })
.limit(5000);
if (q.error && /link_clicks/i.test(q.error.message || '')) {
q = await supabase
.from('ad_daily')
.select(selectNoLc)
.gte('date', since)
.lte('date', until)
.order('date', { ascending: false })
.limit(5000);
}
daily = (q.data as AdDailyRow[]) || [];
}
const warnings: string[] = [];
let metaAds: AdCreativeCard[] = [];
let googleAds: AdCreativeCard[] = [];
let metaClickSplit: {
clicks: number;
linkClicks: number;
spend: number;
} | null = null;
// Fast path: warehouse only — correct spend/clicks/leads from sync, no Graph wait
if (mode === 'fast') {
if (platform === 'meta' || platform === 'all') {
metaAds = buildMetaWarehouseCards(daily, since, until, 16);
metaClickSplit = metaClickSplitFromDaily(daily, since, until);
}
if (platform === 'google' || platform === 'all') {
googleAds = buildGoogleCardsFromDaily(daily, since, until, 24);
}
return NextResponse.json({
ok: true,
mode: 'fast',
tenantId: ctx.tenantId,
since,
until,
ads: [...metaAds, ...googleAds],
metaCount: metaAds.length,
googleCount: googleAds.length,
metaClickSplit,
warnings,
});
}
if (platform === 'meta' || platform === 'all') {
try {
const [meta, clickSplit] = await Promise.all([
fetchMetaCreativeCards({
tenantId: ctx.tenantId,
dailyRows: daily,
since,
until,
limit: 16,
}),
fetchMetaAccountClickSplit({
tenantId: ctx.tenantId,
since,
until,
}).catch(() => null),
]);
metaAds = meta.ads.length
? meta.ads
: buildMetaWarehouseCards(daily, since, until, 16);
metaClickSplit =
clickSplit || metaClickSplitFromDaily(daily, since, until);
if (
meta.warning &&
/sin token meta|sin cuenta meta|sin actividad meta/i.test(meta.warning)
) {
warnings.push(meta.warning);
}
} catch {
metaAds = buildMetaWarehouseCards(daily, since, until, 16);
metaClickSplit = metaClickSplitFromDaily(daily, since, until);
}
}
if (platform === 'google' || platform === 'all') {
try {
const google = await fetchGoogleCreativeCards({
tenantId: ctx.tenantId,
dailyRows: daily,
since,
until,
limit: 24,
});
googleAds = google.ads.length
? google.ads
: buildGoogleCardsFromDaily(daily, since, until, 24);
if (
google.warning &&
/caducado|oauth|no configurada|sin cuenta/i.test(google.warning)
) {
warnings.push(google.warning);
}
} catch {
googleAds = buildGoogleCardsFromDaily(daily, since, until, 24);
}
}
return NextResponse.json({
ok: true,
mode: 'live',
tenantId: ctx.tenantId,
since,
until,
ads: [...metaAds, ...googleAds],
metaCount: metaAds.length,
googleCount: googleAds.length,
metaClickSplit,
warnings,
});
}

View File

@@ -0,0 +1,83 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
export const dynamic = 'force-dynamic';
/** CDN prefixes safe to proxy for Ad Tracker previews. */
const ALLOWED = [
'https://scontent.',
'https://external.',
'https://facebook.com/',
'https://www.facebook.com/',
'https://fbcdn.net/',
'https://scontent.xx.fbcdn.net/',
'https://tpc.googlesyndication.com/',
'https://lh3.googleusercontent.com/',
'https://i.ytimg.com/',
'https://img.youtube.com/',
];
function allowed(url: string): boolean {
if (!url.startsWith('https://')) return false;
if (ALLOWED.some((p) => url.startsWith(p))) return true;
// Meta CDN hosts vary by region: scontent.fmad19-2.fna.fbcdn.net
try {
const host = new URL(url).hostname;
return (
host.endsWith('.fbcdn.net') ||
host.endsWith('.facebook.com') ||
host.endsWith('.cdninstagram.com') ||
host.endsWith('.instagram.com') ||
host.endsWith('.googleusercontent.com') ||
host.endsWith('.googlesyndication.com') ||
host === 'i.ytimg.com' ||
host === 'img.youtube.com'
);
} catch {
return false;
}
}
/**
* GET /api/ad-tracker/media?url=…
* Proxies Meta/Google creative images so the browser always gets a same-origin preview.
*/
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 });
}
const url = req.nextUrl.searchParams.get('url') || '';
if (!allowed(url)) {
return NextResponse.json({ error: 'url_not_allowed' }, { status: 400 });
}
try {
const res = await fetch(url, {
headers: {
'User-Agent':
'Mozilla/5.0 (compatible; 4VenturesAdTracker/1.0; +https://app.4ventures.es)',
Accept: 'image/*,*/*;q=0.8',
},
cache: 'force-cache',
next: { revalidate: 86400 },
});
if (!res.ok) {
return NextResponse.json({ error: 'upstream' }, { status: 502 });
}
const contentType = res.headers.get('content-type') || 'image/jpeg';
const body = await res.arrayBuffer();
return new NextResponse(body, {
headers: {
'Content-Type': contentType,
'Cache-Control': 'private, max-age=3600',
},
});
} catch {
return NextResponse.json({ error: 'fetch_failed' }, { status: 502 });
}
}

View File

@@ -0,0 +1,91 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import {
VIEW_TENANT_COOKIE,
VIEW_TENANT_NAME_COOKIE,
VIEW_TENANT_SLUG_COOKIE,
isAgencyRole,
serviceHeaders,
supabaseUrl,
userRole,
} from '@/lib/agency';
export const dynamic = 'force-dynamic';
/** POST { tenantId } — set superadmin view context cookie. null clears. */
export async function POST(req: NextRequest) {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
if (!isAgencyRole(userRole(user))) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const body = await req.json().catch(() => ({}));
const tenantId =
typeof body.tenantId === 'string' && body.tenantId.trim()
? body.tenantId.trim()
: null;
const cookieOpts = {
path: '/',
maxAge: 60 * 60 * 24 * 30,
sameSite: 'lax' as const,
secure: process.env.NODE_ENV === 'production',
httpOnly: false,
};
if (!tenantId) {
const res = NextResponse.json({ ok: true, cleared: true });
res.cookies.set(VIEW_TENANT_COOKIE, '', { ...cookieOpts, maxAge: 0 });
res.cookies.set(VIEW_TENANT_SLUG_COOKIE, '', { ...cookieOpts, maxAge: 0 });
res.cookies.set(VIEW_TENANT_NAME_COOKIE, '', { ...cookieOpts, maxAge: 0 });
return res;
}
const headers = serviceHeaders();
if (!headers) {
return NextResponse.json(
{ error: 'Falta SUPABASE_SERVICE_ROLE_KEY' },
{ status: 503 }
);
}
const r = await fetch(
`${supabaseUrl()}/rest/v1/tenants?id=eq.${encodeURIComponent(tenantId)}&select=id,name,slug`,
{ headers, cache: 'no-store' }
);
if (!r.ok) {
return NextResponse.json({ error: 'Tenant lookup failed' }, { status: 500 });
}
const rows = await r.json();
const tenant = Array.isArray(rows) ? rows[0] : null;
if (!tenant) {
return NextResponse.json({ error: 'Tenant no encontrado' }, { status: 404 });
}
const res = NextResponse.json({
ok: true,
tenant: { id: tenant.id, name: tenant.name, slug: tenant.slug },
});
res.cookies.set(VIEW_TENANT_COOKIE, tenant.id, cookieOpts);
res.cookies.set(VIEW_TENANT_SLUG_COOKIE, tenant.slug || '', cookieOpts);
res.cookies.set(VIEW_TENANT_NAME_COOKIE, tenant.name || '', cookieOpts);
return res;
}
export async function GET() {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// readable from client via document.cookie too; this is for SSR helpers
return NextResponse.json({ ok: true });
}

View File

@@ -0,0 +1,76 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import {
isAgencyRole,
serviceHeaders,
userRole,
} from '@/lib/agency';
import { discoverAndMatch, applyProviderMatches } from '@/lib/ads-discovery';
import type { AccountMatch } from '@/lib/match';
export const dynamic = 'force-dynamic';
export const maxDuration = 120;
async function requireAgency() {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user || !isAgencyRole(userRole(user))) {
return { error: NextResponse.json({ error: 'Forbidden' }, { status: 403 }) };
}
if (!serviceHeaders()) {
return {
error: NextResponse.json({ error: 'no service role' }, { status: 503 }),
};
}
return { user };
}
/** POST — discover Meta/Google accounts and auto-match to tenants.
* Body: { apply?: boolean, providers?: ['meta_ads'|'google_ads'],
* assign?: { provider, tenantId, accountId, accountName } }
*/
export async function POST(req: NextRequest) {
const gate = await requireAgency();
if ('error' in gate && gate.error) return gate.error;
const body = await req.json().catch(() => ({}));
if (body.assign?.tenantId && body.assign?.accountId && body.assign?.provider) {
const provider = body.assign.provider as 'meta_ads' | 'google_ads';
if (provider !== 'meta_ads' && provider !== 'google_ads') {
return NextResponse.json({ error: 'provider inválido' }, { status: 400 });
}
const fake: AccountMatch[] = [
{
tenantId: body.assign.tenantId,
tenantName: '',
tenantSlug: '',
accountId: String(body.assign.accountId),
accountName: String(body.assign.accountName || body.assign.accountId),
score: 100,
status: 'matched',
},
];
const { linked } = await applyProviderMatches(
provider,
fake,
provider === 'meta_ads' ? 'Meta Ads' : 'Google Ads'
);
return NextResponse.json({ linked, manual: true });
}
try {
const result = await discoverAndMatch({
apply: body.apply !== false,
providers: body.providers,
});
return NextResponse.json(result);
} catch (e) {
return NextResponse.json(
{ error: e instanceof Error ? e.message : 'discover failed' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,25 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { isAgencyRole, userRole } from '@/lib/agency';
import { saveMetaAccessToken } from '@/lib/ads-discovery';
export const dynamic = 'force-dynamic';
/** POST { token } — save Meta system user token (after App Review). */
export async function POST(req: NextRequest) {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user || !isAgencyRole(userRole(user))) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const body = await req.json().catch(() => ({}));
const token = typeof body.token === 'string' ? body.token : '';
const result = await saveMetaAccessToken(token);
if (!result.ok) {
return NextResponse.json({ error: result.error }, { status: 400 });
}
return NextResponse.json({ ok: true, name: result.name });
}

View File

@@ -0,0 +1,31 @@
import { NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { isAgencyRole, userRole } from '@/lib/agency';
import {
googleAdsConfigured,
metaConfigured,
metaEnvConfigured,
} from '@/lib/ads-discovery';
export const dynamic = 'force-dynamic';
/** GET — whether Meta / Google Ads discovery credentials are ready. */
export async function GET() {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user || !isAgencyRole(userRole(user))) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const metaReady = await metaConfigured();
return NextResponse.json({
meta: {
configured: metaReady,
envFallback: metaEnvConfigured(),
pendingReview: !metaReady,
},
googleAds: { configured: googleAdsConfigured() },
});
}

View File

@@ -0,0 +1,44 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { isAgencyRole, userRole } from '@/lib/agency';
import { syncAdsForTenants } from '@/lib/ads-sync';
export const dynamic = 'force-dynamic';
export const maxDuration = 300;
/** POST { tenantId?, days? } — sync Meta/Google insights → ad_daily + kpi_weekly */
export async function POST(req: NextRequest) {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user || !isAgencyRole(userRole(user))) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const body = await req.json().catch(() => ({}));
const tenantId =
typeof body.tenantId === 'string' ? body.tenantId : undefined;
const days =
typeof body.days === 'number' && body.days > 0 && body.days <= 1000
? body.days
: 30;
try {
const { results, days: d } = await syncAdsForTenants({ tenantId, days });
const ok = results.filter((r) => r.metaRows + r.googleRows > 0).length;
const errs = results.filter((r) => r.errors.length).length;
return NextResponse.json({
ok: true,
days: d,
synced: ok,
withErrors: errs,
results,
});
} catch (e) {
return NextResponse.json(
{ error: e instanceof Error ? e.message : 'sync error' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,104 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import {
isAgencyRole,
serviceHeaders,
supabaseUrl,
userRole,
} from '@/lib/agency';
export const dynamic = 'force-dynamic';
export async function GET() {
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: 'Forbidden' }, { status: 403 });
}
const headers = serviceHeaders();
if (!headers) {
return NextResponse.json({ alerts: [], warning: 'no service role' });
}
const r = await fetch(
`${supabaseUrl()}/rest/v1/agency_alerts?select=*&order=created_at.desc&limit=50`,
{ headers, cache: 'no-store' }
);
if (!r.ok) {
return NextResponse.json({
alerts: [],
warning: 'Aplicar sql/agency_portfolio.sql',
});
}
return NextResponse.json({ alerts: await r.json() });
}
/** POST { id } mark read — or { title, body, severity, tenantId } create */
export async function POST(req: NextRequest) {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
if (!isAgencyRole(userRole(user))) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const headers = serviceHeaders();
if (!headers) {
return NextResponse.json({ error: 'no service role' }, { status: 503 });
}
const body = await req.json().catch(() => ({}));
const base = supabaseUrl();
if (typeof body.id === 'string' && body.markRead) {
const r = await fetch(
`${base}/rest/v1/agency_alerts?id=eq.${encodeURIComponent(body.id)}`,
{
method: 'PATCH',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ read_at: new Date().toISOString() }),
}
);
if (!r.ok) {
return NextResponse.json({ error: await r.text() }, { status: 500 });
}
return NextResponse.json({ ok: true });
}
const title = typeof body.title === 'string' ? body.title.trim() : '';
if (!title) {
return NextResponse.json({ error: 'title requerido' }, { status: 400 });
}
const row = {
title,
body: typeof body.body === 'string' ? body.body : null,
severity: body.severity || 'info',
tenant_id: body.tenantId || null,
source: body.source || 'manual',
href: body.href || null,
};
const r = await fetch(`${base}/rest/v1/agency_alerts`, {
method: 'POST',
headers: {
...headers,
'Content-Type': 'application/json',
Prefer: 'return=representation',
},
body: JSON.stringify(row),
});
if (!r.ok) {
return NextResponse.json({ error: await r.text() }, { status: 500 });
}
const created = await r.json();
return NextResponse.json({ alert: created[0] || created }, { status: 201 });
}

View File

@@ -38,6 +38,53 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: error.message || 'Error al provisionar cliente en base de datos.' }, { status: 500 });
}
// Seed integration slots (best-effort; ignore if table missing)
try {
const tenantId =
data?.tenant_id || data?.id || data?.client?.tenant_id || null;
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY?.trim();
const url = process.env.NEXT_PUBLIC_SUPABASE_URL || 'https://supabase.4ventures.es';
if (tenantId && serviceKey) {
const providers = [
['meta_ads', 'Meta Ads'],
['google_ads', 'Google Ads (MCC)'],
['ga4', 'Google Analytics 4'],
['search_console', 'Search Console'],
['tag_manager', 'Tag Manager'],
['clientify', 'Clientify'],
['klaviyo', 'Klaviyo'],
['mailchimp', 'Mailchimp'],
['google_drive', 'Google Drive'],
['wordpress', 'WordPress'],
['prestashop', 'PrestaShop'],
['shopify', 'Shopify'],
['instagram', 'Instagram'],
['facebook', 'Facebook Page'],
['tiktok', 'TikTok'],
['linkedin', 'LinkedIn'],
];
await fetch(`${url}/rest/v1/integrations`, {
method: 'POST',
headers: {
apikey: serviceKey,
Authorization: `Bearer ${serviceKey}`,
'Content-Type': 'application/json',
Prefer: 'resolution=ignore-duplicates',
},
body: JSON.stringify(
providers.map(([provider, label]) => ({
tenant_id: tenantId,
provider,
status: 'disconnected',
label,
}))
),
});
}
} catch (e) {
console.warn('seed integrations skipped', e);
}
return NextResponse.json({ success: true, client: data });
} catch (err: any) {
console.error('Server error creating client:', err);

View File

@@ -0,0 +1,88 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { isAgencyRole, userRole } from '@/lib/agency';
import {
exchangeCode,
fetchGoogleEmail,
saveAgencyOAuth,
} from '@/lib/google-drive';
import { bootstrapAgencyGoogle } from '@/lib/agency-google-bootstrap';
export const dynamic = 'force-dynamic';
export const maxDuration = 120;
function appUrl(): string {
return (
process.env.NEXT_PUBLIC_APP_URL?.trim() ||
'https://app.4ventures.es'
);
}
/** GET — OAuth callback: save tokens + auto-emparejar Drive/GA4/GSC/Ads → fichas. */
export async function GET(req: NextRequest) {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user || !isAgencyRole(userRole(user))) {
return NextResponse.redirect(`${appUrl()}/login`);
}
const code = req.nextUrl.searchParams.get('code');
const state = req.nextUrl.searchParams.get('state');
const err = req.nextUrl.searchParams.get('error');
const cookieState = req.cookies.get('4v_drive_oauth_state')?.value;
if (err) {
return NextResponse.redirect(
`${appUrl()}/dashboard/agencia?drive=error&msg=${encodeURIComponent(err)}`
);
}
if (!code || !state || !cookieState || state !== cookieState) {
return NextResponse.redirect(
`${appUrl()}/dashboard/agencia?drive=error&msg=state`
);
}
try {
const tokens = await exchangeCode(code);
const email = await fetchGoogleEmail(tokens.access_token);
await saveAgencyOAuth({
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token || null,
expiresIn: tokens.expires_in,
scopes: tokens.scope,
email,
});
let linked = 0;
let ambiguous = 0;
let bootstrapMsg = '';
try {
const boot = await bootstrapAgencyGoogle({
includeGoogleAds: true,
syncOrganic: true,
organicDays: 28,
});
linked = boot.drive?.linked || 0;
ambiguous = boot.drive?.ambiguous || 0;
bootstrapMsg = boot.message;
} catch (e) {
bootstrapMsg = e instanceof Error ? e.message : 'bootstrap failed';
}
const res = NextResponse.redirect(
`${appUrl()}/dashboard/agencia?drive=ok&linked=${linked}&ambiguous=${ambiguous}` +
(bootstrapMsg
? `&boot=${encodeURIComponent(bootstrapMsg.slice(0, 160))}`
: '')
);
res.cookies.set('4v_drive_oauth_state', '', { maxAge: 0, path: '/' });
return res;
} catch (e) {
const msg = e instanceof Error ? e.message : 'oauth_failed';
return NextResponse.redirect(
`${appUrl()}/dashboard/agencia?drive=error&msg=${encodeURIComponent(msg.slice(0, 120))}`
);
}
}

View File

@@ -0,0 +1,42 @@
import { NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { isAgencyRole, userRole } from '@/lib/agency';
import {
driveAuthUrl,
driveConfigured,
} from '@/lib/google-drive';
import { randomBytes } from 'crypto';
export const dynamic = 'force-dynamic';
/** GET — start Google OAuth for agency Drive (marketing@). */
export async function GET() {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user || !isAgencyRole(userRole(user))) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
if (!driveConfigured()) {
return NextResponse.json(
{
error:
'Faltan GOOGLE_OAUTH_CLIENT_ID y GOOGLE_OAUTH_CLIENT_SECRET en el entorno.',
},
{ status: 503 }
);
}
const state = randomBytes(24).toString('hex');
const res = NextResponse.redirect(driveAuthUrl(state));
res.cookies.set('4v_drive_oauth_state', state, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
maxAge: 600,
});
return res;
}

View File

@@ -0,0 +1,109 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import {
isAgencyRole,
serviceHeaders,
supabaseUrl,
userRole,
} from '@/lib/agency';
import {
DRIVE_PROVIDER,
applyMatches,
listDriveFolders,
matchFoldersToTenants,
} from '@/lib/google-drive';
export const dynamic = 'force-dynamic';
async function requireAgency() {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user || !isAgencyRole(userRole(user))) {
return { error: NextResponse.json({ error: 'Forbidden' }, { status: 403 }) };
}
const headers = serviceHeaders();
if (!headers) {
return {
error: NextResponse.json({ error: 'no service role' }, { status: 503 }),
};
}
return { headers };
}
/** POST — re-run auto-match (or body.assign for manual pick). */
export async function POST(req: NextRequest) {
const gate = await requireAgency();
if ('error' in gate && gate.error) return gate.error;
const { headers } = gate as {
headers: { apikey: string; Authorization: string };
};
const body = await req.json().catch(() => ({}));
// Manual assign: { tenantId, folderId, folderName }
if (body.tenantId && body.folderId) {
const patch = {
status: 'connected',
external_account_id: String(body.folderId),
label: String(body.folderName || 'Google Drive'),
last_sync_at: new Date().toISOString(),
error_message: null,
meta: {
folder_name: body.folderName || null,
match_score: 100,
matched_at: new Date().toISOString(),
manual: true,
},
updated_at: new Date().toISOString(),
};
const url = `${supabaseUrl()}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(body.tenantId)}&provider=eq.${DRIVE_PROVIDER}`;
const r = await fetch(url, {
method: 'PATCH',
headers: {
...headers,
'Content-Type': 'application/json',
Prefer: 'return=representation',
},
body: JSON.stringify(patch),
});
if (!r.ok) {
return NextResponse.json({ error: await r.text() }, { status: 500 });
}
const rows = await r.json();
return NextResponse.json({ integration: rows[0] || rows });
}
try {
const tRes = await fetch(
`${supabaseUrl()}/rest/v1/tenants?select=id,name,slug&order=name.asc`,
{ headers, cache: 'no-store' }
);
if (!tRes.ok) {
return NextResponse.json({ error: await tRes.text() }, { status: 500 });
}
const tenants = (await tRes.json()) as {
id: string;
name: string;
slug: string;
}[];
const folders = await listDriveFolders();
const matches = await matchFoldersToTenants(tenants, folders);
const apply = body.apply !== false;
let linked = 0;
if (apply) {
linked = (await applyMatches(matches)).linked;
}
return NextResponse.json({
linked,
folders: folders.length,
matches,
});
} catch (e) {
return NextResponse.json(
{ error: e instanceof Error ? e.message : 'match failed' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,31 @@
import { NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { isAgencyRole, userRole } from '@/lib/agency';
import { driveConfigured, getAgencyOAuthStatus } from '@/lib/google-drive';
export const dynamic = 'force-dynamic';
/** GET — agency Drive connection status (no tokens). */
export async function GET() {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user || !isAgencyRole(userRole(user))) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
try {
const status = await getAgencyOAuthStatus();
return NextResponse.json({
...status,
configured: driveConfigured(),
});
} catch (e) {
return NextResponse.json({
connected: false,
configured: driveConfigured(),
warning: e instanceof Error ? e.message : 'error',
});
}
}

View File

@@ -0,0 +1,197 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import {
isAgencyRole,
serviceHeaders,
supabaseUrl,
userRole,
} from '@/lib/agency';
import { getAgencyOAuthStatus } from '@/lib/google-drive';
import { bootstrapAgencyGoogle } from '@/lib/agency-google-bootstrap';
import {
applyOrganicMatches,
listGa4Properties,
listGscSites,
matchOrganicToTenants,
syncAllConnectedOrganic,
} from '@/lib/google-organic';
export const dynamic = 'force-dynamic';
export const maxDuration = 120;
async function requireAgency() {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user || !isAgencyRole(userRole(user))) {
return { error: NextResponse.json({ error: 'Forbidden' }, { status: 403 }) };
}
const headers = serviceHeaders();
if (!headers) {
return {
error: NextResponse.json({ error: 'no service role' }, { status: 503 }),
};
}
return { headers };
}
/** GET — list GA4 properties + GSC sites visible to marketing@ token. */
export async function GET() {
const gate = await requireAgency();
if ('error' in gate && gate.error) return gate.error;
const status = await getAgencyOAuthStatus();
if (!status.connected) {
return NextResponse.json({
connected: false,
warning: 'Conecta Google (marketing@) en Agencia primero.',
ga4: [],
gsc: [],
});
}
try {
const [ga4, gsc] = await Promise.all([
status.hasAnalytics !== false
? listGa4Properties().catch((e) => {
throw Object.assign(e, { where: 'ga4' });
})
: Promise.resolve([]),
status.hasSearchConsole !== false
? listGscSites().catch((e) => {
throw Object.assign(e, { where: 'gsc' });
})
: Promise.resolve([]),
]);
return NextResponse.json({
connected: true,
email: status.email,
hasAnalytics: status.hasAnalytics,
hasSearchConsole: status.hasSearchConsole,
ga4,
gsc,
warning:
!status.hasAnalytics || !status.hasSearchConsole
? 'Reconecta Google para conceder scopes Analytics + Search Console (además de Drive).'
: null,
});
} catch (e) {
const msg = e instanceof Error ? e.message : 'error';
const scopeHint = msg.toLowerCase().includes('scope')
? ' Reconecta Google con scopes Analytics + GSC.'
: '';
return NextResponse.json(
{ error: msg + scopeHint, connected: true, email: status.email },
{ status: 500 }
);
}
}
/**
* POST
* - { action: 'bootstrap' } → Drive + GA4 + GSC + Google Ads → fichas + sync
* - { action: 'match', apply? } → solo GA4/GSC
* - { action: 'sync', tenantId?, days? } → sync organic_daily
*/
export async function POST(req: NextRequest) {
const gate = await requireAgency();
if ('error' in gate && gate.error) return gate.error;
const { headers } = gate as {
headers: { apikey: string; Authorization: string };
};
const body = await req.json().catch(() => ({}));
const action =
body.action === 'sync'
? 'sync'
: body.action === 'bootstrap'
? 'bootstrap'
: 'match';
if (action === 'bootstrap') {
try {
const result = await bootstrapAgencyGoogle({
includeGoogleAds: body.includeGoogleAds !== false,
syncOrganic: body.syncOrganic !== false,
organicDays: Number(body.days) || 28,
});
return NextResponse.json(result, { status: result.ok ? 200 : 422 });
} catch (e) {
return NextResponse.json(
{ error: e instanceof Error ? e.message : 'bootstrap error' },
{ status: 500 }
);
}
}
if (action === 'match') {
try {
const [ga4, gsc, tRes] = await Promise.all([
listGa4Properties(),
listGscSites(),
fetch(
`${supabaseUrl()}/rest/v1/tenants?select=id,name,slug&order=name.asc`,
{ headers, cache: 'no-store' }
),
]);
if (!tRes.ok) {
return NextResponse.json(
{ error: await tRes.text() },
{ status: 500 }
);
}
const tenants = (await tRes.json()) as {
id: string;
name: string;
slug: string;
}[];
const matches = await matchOrganicToTenants(tenants, ga4, gsc);
let applied = { ga4: 0, gsc: 0 };
if (body.apply !== false) {
applied = await applyOrganicMatches(
matches.filter(
(m) =>
(m.ga4 && m.ga4.score >= 50) || (m.gsc && m.gsc.score >= 40)
)
);
}
return NextResponse.json({
ga4Count: ga4.length,
gscCount: gsc.length,
matches,
applied,
});
} catch (e) {
const msg = e instanceof Error ? e.message : 'error';
return NextResponse.json(
{
error:
msg +
(msg.toLowerCase().includes('scope')
? ' → Reconecta Google (scopes Analytics + GSC).'
: ''),
},
{ status: 500 }
);
}
}
const days = Math.min(90, Math.max(3, Number(body.days) || 28));
const tenantFilter =
typeof body.tenantId === 'string' ? body.tenantId.trim() : '';
try {
const result = await syncAllConnectedOrganic({
tenantId: tenantFilter || null,
days,
});
return NextResponse.json(result);
} catch (e) {
return NextResponse.json(
{ error: e instanceof Error ? e.message : 'sync error' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,242 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import {
INTEGRATION_PROVIDERS,
isAgencyRole,
serviceHeaders,
supabaseUrl,
userRole,
} from '@/lib/agency';
import {
activateGoogleFromFicha,
LIVE_GOOGLE_PROVIDERS,
normalizeDriveFolderId,
} from '@/lib/integration-activate';
export const dynamic = 'force-dynamic';
async function requireAgency() {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) return { error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) };
if (!isAgencyRole(userRole(user))) {
return { error: NextResponse.json({ error: 'Forbidden' }, { status: 403 }) };
}
const headers = serviceHeaders();
if (!headers) {
return {
error: NextResponse.json(
{ error: 'Falta SUPABASE_SERVICE_ROLE_KEY' },
{ status: 503 }
),
};
}
return { user, headers };
}
/** GET ?tenantId= — integrations for one tenant (seed missing providers). */
export async function GET(req: NextRequest) {
const gate = await requireAgency();
if ('error' in gate && gate.error) return gate.error;
const { headers } = gate as { headers: { apikey: string; Authorization: string } };
const tenantId = req.nextUrl.searchParams.get('tenantId');
if (!tenantId) {
return NextResponse.json({ error: 'tenantId requerido' }, { status: 400 });
}
const base = supabaseUrl();
const listRes = await fetch(
`${base}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(tenantId)}&select=*`,
{ headers, cache: 'no-store' }
);
if (!listRes.ok) {
const msg = await listRes.text();
return NextResponse.json(
{
integrations: [],
providers: INTEGRATION_PROVIDERS,
warning: msg.includes('does not exist')
? 'Aplicar sql/agency_portfolio.sql'
: msg.slice(0, 200),
},
{ status: 200 }
);
}
let integrations = await listRes.json();
const have = new Set(
(integrations as { provider: string }[]).map((i) => i.provider)
);
const missing = INTEGRATION_PROVIDERS.filter((p) => !have.has(p.id));
if (missing.length) {
const rows = missing.map((p) => ({
tenant_id: tenantId,
provider: p.id,
status: 'disconnected',
label: p.label,
}));
await fetch(`${base}/rest/v1/integrations`, {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json', Prefer: 'return=representation' },
body: JSON.stringify(rows),
});
const again = await fetch(
`${base}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(tenantId)}&select=*`,
{ headers, cache: 'no-store' }
);
if (again.ok) integrations = await again.json();
}
return NextResponse.json({
integrations,
providers: INTEGRATION_PROVIDERS,
});
}
/** PATCH — update integration; GA4/GSC/Drive verify + sync via agency Google OAuth. */
export async function PATCH(req: NextRequest) {
const gate = await requireAgency();
if ('error' in gate && gate.error) return gate.error;
const { headers } = gate as { headers: { apikey: string; Authorization: string } };
const body = await req.json().catch(() => ({}));
const tenantId = typeof body.tenantId === 'string' ? body.tenantId.trim() : '';
const provider = typeof body.provider === 'string' ? body.provider.trim() : '';
const status = typeof body.status === 'string' ? body.status.trim() : '';
const externalAccountId =
typeof body.externalAccountId === 'string'
? body.externalAccountId.trim()
: undefined;
const label =
typeof body.label === 'string' ? body.label.trim() : undefined;
const errorMessage =
typeof body.errorMessage === 'string' ? body.errorMessage.trim() : undefined;
const enabled =
typeof body.enabled === 'boolean' ? body.enabled : undefined;
if (!tenantId || !provider) {
return NextResponse.json(
{ error: 'tenantId y provider requeridos' },
{ status: 400 }
);
}
if (status && !['disconnected', 'pending', 'connected', 'error'].includes(status)) {
return NextResponse.json({ error: 'status inválido' }, { status: 400 });
}
const wantsLiveConnect =
LIVE_GOOGLE_PROVIDERS.has(provider) &&
(status === 'connected' ||
(status === 'pending' && Boolean(externalAccountId)));
if (wantsLiveConnect) {
let rawId = externalAccountId || '';
if (!rawId) {
const cur = await fetch(
`${supabaseUrl()}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(tenantId)}&provider=eq.${encodeURIComponent(provider)}&select=external_account_id`,
{ headers, cache: 'no-store' }
);
if (cur.ok) {
const rows = (await cur.json()) as { external_account_id?: string }[];
rawId = rows[0]?.external_account_id || '';
}
}
if (!rawId) {
return NextResponse.json(
{
error: 'Indica ID o URL antes de conectar',
activation: { ok: false, message: 'Falta ID' },
},
{ status: 400 }
);
}
const activation = await activateGoogleFromFicha({
tenantId,
provider: provider as 'ga4' | 'search_console' | 'google_drive',
rawId,
});
const list = await fetch(
`${supabaseUrl()}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(tenantId)}&provider=eq.${encodeURIComponent(provider)}&select=*`,
{ headers, cache: 'no-store' }
);
const rows = list.ok ? await list.json() : [];
return NextResponse.json(
{
integration: rows[0] || null,
activation,
error: activation.ok ? undefined : activation.errorMessage || activation.message,
},
{ status: activation.ok ? 200 : 422 }
);
}
const patch: Record<string, unknown> = {
updated_at: new Date().toISOString(),
};
if (status) patch.status = status;
if (externalAccountId !== undefined) {
let id = externalAccountId || null;
if (provider === 'google_drive' && id) {
id = normalizeDriveFolderId(id) || id;
}
patch.external_account_id = id;
}
if (label !== undefined) patch.label = label || null;
if (errorMessage !== undefined) patch.error_message = errorMessage || null;
if (enabled !== undefined) patch.enabled = enabled;
if (status === 'connected') {
patch.error_message = null;
}
if (!status && enabled === undefined && externalAccountId === undefined) {
return NextResponse.json(
{ error: 'status, enabled o externalAccountId requeridos' },
{ status: 400 }
);
}
const base = supabaseUrl();
const url = `${base}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(tenantId)}&provider=eq.${encodeURIComponent(provider)}`;
const r = await fetch(url, {
method: 'PATCH',
headers: {
...headers,
'Content-Type': 'application/json',
Prefer: 'return=representation',
},
body: JSON.stringify(patch),
});
if (!r.ok) {
const ins = await fetch(`${base}/rest/v1/integrations`, {
method: 'POST',
headers: {
...headers,
'Content-Type': 'application/json',
Prefer: 'return=representation,resolution=merge-duplicates',
},
body: JSON.stringify({
tenant_id: tenantId,
provider,
status: status || 'disconnected',
...patch,
}),
});
if (!ins.ok) {
return NextResponse.json(
{ error: await ins.text() },
{ status: 500 }
);
}
const created = await ins.json();
return NextResponse.json({ integration: created[0] || created });
}
const updated = await r.json();
return NextResponse.json({ integration: updated[0] || updated });
}

View File

@@ -0,0 +1,141 @@
import { NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import {
INTEGRATION_PROVIDERS,
isAgencyRole,
serviceHeaders,
supabaseUrl,
userRole,
} from '@/lib/agency';
export const dynamic = 'force-dynamic';
/** GET portfolio: tenants + integration summary + unread alerts (agency only). */
export async function GET() {
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: 'Forbidden' }, { status: 403 });
}
const headers = serviceHeaders();
if (!headers) {
return NextResponse.json(
{ error: 'Falta SUPABASE_SERVICE_ROLE_KEY' },
{ status: 503 }
);
}
const base = supabaseUrl();
const [tenantsRes, integRes, alertsRes] = await Promise.all([
fetch(
`${base}/rest/v1/tenants?select=id,name,slug,created_at,agency_followed,agency_notes&order=name.asc`,
{
headers,
cache: 'no-store',
}
),
fetch(
`${base}/rest/v1/integrations?select=id,tenant_id,provider,status,label,external_account_id,last_sync_at,error_message,enabled,meta`,
{ headers, cache: 'no-store' }
),
fetch(
`${base}/rest/v1/agency_alerts?select=*&read_at=is.null&order=created_at.desc&limit=40`,
{ headers, cache: 'no-store' }
),
]);
let tenants = tenantsRes.ok ? await tenantsRes.json() : [];
// Fallback if agency_followed column missing
if (!tenantsRes.ok) {
const again = await fetch(
`${base}/rest/v1/tenants?select=id,name,slug,created_at&order=name.asc`,
{ headers, cache: 'no-store' }
);
tenants = again.ok ? await again.json() : [];
}
let integrations: unknown[] = [];
let integMissing = false;
if (integRes.ok) {
integrations = await integRes.json();
} else {
const msg = await integRes.text();
integMissing = msg.toLowerCase().includes('does not exist');
}
const alerts = alertsRes.ok ? await alertsRes.json() : [];
type Integ = {
tenant_id: string;
provider: string;
status: string;
label?: string | null;
external_account_id?: string | null;
enabled?: boolean;
error_message?: string | null;
meta?: Record<string, unknown> | null;
};
const byTenant = new Map<string, Integ[]>();
for (const row of integrations as Integ[]) {
const list = byTenant.get(row.tenant_id) || [];
list.push(row);
byTenant.set(row.tenant_id, list);
}
const portfolio = (
tenants as {
id: string;
name: string;
slug: string;
created_at: string;
agency_followed?: boolean;
agency_notes?: string | null;
}[]
).map((t) => {
const rows = byTenant.get(t.id) || [];
const active = rows.filter((r) => r.enabled !== false);
const connected = active.filter((r) => r.status === 'connected').length;
const errors = active.filter((r) => r.status === 'error').length;
const pending = active.filter((r) => r.status === 'pending').length;
const byProvider: Record<string, Integ | undefined> = {};
for (const p of INTEGRATION_PROVIDERS) {
byProvider[p.id] = rows.find((r) => r.provider === p.id);
}
return {
...t,
agency_followed: t.agency_followed !== false,
integrations: rows,
byProvider,
stats: {
connected,
errors,
pending,
enabledChannels: active.length,
totalProviders: INTEGRATION_PROVIDERS.length,
health:
errors > 0
? 'critical'
: connected === 0
? 'empty'
: pending > 0
? 'warning'
: 'ok',
},
};
});
return NextResponse.json({
portfolio,
alerts,
providers: INTEGRATION_PROVIDERS,
schemaReady: !integMissing,
warning: integMissing
? 'Falta aplicar sql/agency_portfolio.sql en Supabase'
: null,
});
}

View File

@@ -0,0 +1,123 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import {
isAgencyRole,
serviceHeaders,
supabaseUrl,
userRole,
} from '@/lib/agency';
import {
syncAllSuperhumanTenants,
superhumanConfigured,
} from '@/lib/superhuman-docs';
export const dynamic = 'force-dynamic';
export const maxDuration = 300;
async function requireAgency() {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user || !isAgencyRole(userRole(user))) {
return {
error: NextResponse.json(
{
error:
'Forbidden: sincronizar Superhuman requiere rol agencia (admin/team).',
},
{ status: 403 }
),
};
}
if (!superhumanConfigured()) {
return {
error: NextResponse.json(
{ error: 'Falta SUPERHUMAN_DOCS_API_KEY en el entorno' },
{ status: 503 }
),
};
}
const headers = serviceHeaders();
if (!headers) {
return {
error: NextResponse.json({ error: 'no service role' }, { status: 503 }),
};
}
return { headers, user };
}
/** GET — match status of Superhuman docs ↔ tenants. */
export async function GET() {
const gate = await requireAgency();
if ('error' in gate && gate.error) return gate.error;
try {
const { listDocs, matchDocsToTenants, SUPERHUMAN_PROVIDER } = await import(
'@/lib/superhuman-docs'
);
const headers = (gate as { headers: { apikey: string; Authorization: string } })
.headers;
const [docs, tenantsRes] = await Promise.all([
listDocs(),
fetch(
`${supabaseUrl()}/rest/v1/tenants?select=id,name,slug&order=name.asc`,
{ headers, cache: 'no-store' }
),
]);
const tenants = tenantsRes.ok
? ((await tenantsRes.json()) as { id: string; name: string; slug: string }[])
: [];
const matches = matchDocsToTenants(tenants, docs);
const integRes = await fetch(
`${supabaseUrl()}/rest/v1/integrations?provider=eq.${SUPERHUMAN_PROVIDER}&select=tenant_id,status,external_account_id,label,last_sync_at,meta,error_message`,
{ headers, cache: 'no-store' }
);
const integrations = integRes.ok ? await integRes.json() : [];
return NextResponse.json({
configured: true,
docCount: docs.length,
matches,
integrations,
});
} catch (e) {
return NextResponse.json(
{ error: e instanceof Error ? e.message : 'sync status failed' },
{ status: 500 }
);
}
}
/**
* POST — sync seguimiento pages into portal.
* body: { tenantId?: string, exportBodies?: boolean, maxEntries?: number }
*/
export async function POST(req: NextRequest) {
const gate = await requireAgency();
if ('error' in gate && gate.error) return gate.error;
const body = await req.json().catch(() => ({}));
const onlyTenant: string | undefined = body.tenantId
? String(body.tenantId)
: undefined;
const exportBodies = body.exportBodies !== false;
const maxEntries = Math.min(Number(body.maxEntries) || 80, 150);
try {
const result = await syncAllSuperhumanTenants({
tenantId: onlyTenant || null,
maxEntries,
exportBodies,
});
return NextResponse.json({
ok: true,
...result,
});
} catch (e) {
return NextResponse.json(
{ error: e instanceof Error ? e.message : 'sync failed' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,187 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { isAgencyRole, serviceHeaders, supabaseUrl, userRole } from '@/lib/agency';
import { resolveActiveTenant } from '@/lib/tenant-context';
import { syncAdsForTenants } from '@/lib/ads-sync';
import { syncAllClientifyTenants } from '@/lib/clientify-sync';
import { syncAllConnectedOrganic } from '@/lib/google-organic';
import {
getAgencyOAuthStatus,
applyMatches,
matchFoldersToTenants,
listClientFoldersUnderClientes,
} from '@/lib/google-drive';
import {
syncAllSuperhumanTenants,
superhumanConfigured,
} from '@/lib/superhuman-docs';
export const dynamic = 'force-dynamic';
export const maxDuration = 300;
export type SyncModule =
| 'metrics'
| 'crm'
| 'seguimiento'
| 'ads'
| 'social'
| 'organico'
| 'activos'
| 'all';
/**
* POST { module, days?, tenantId? }
* Agency-triggered refresh for dashboard modules.
*/
export async function POST(req: NextRequest) {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user || !isAgencyRole(userRole(user))) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const body = await req.json().catch(() => ({}));
const module = (body.module || 'all') as SyncModule;
const days =
typeof body.days === 'number' && body.days > 0 && body.days <= 90
? body.days
: 14;
const ctx = await resolveActiveTenant(user);
const tenantId =
typeof body.tenantId === 'string'
? body.tenantId
: ctx.portfolioWide
? undefined
: ctx.tenantId || undefined;
const out: Record<string, unknown> = {
ok: true,
module,
tenantId: tenantId || null,
at: new Date().toISOString(),
};
const want = (m: SyncModule) => module === 'all' || module === m;
try {
if (want('crm') || want('metrics')) {
out.crm = await syncAllClientifyTenants({ tenantId });
}
} catch (e) {
out.crm = {
ok: false,
error: e instanceof Error ? e.message : 'crm failed',
};
}
try {
if (want('ads') || want('metrics')) {
const ads = await syncAdsForTenants({ tenantId, days });
out.ads = {
ok: true,
synced: ads.results.filter((r) => r.metaRows + r.googleRows > 0).length,
withErrors: ads.results.filter((r) => r.errors.length).length,
results: ads.results.slice(0, 30),
};
}
} catch (e) {
out.ads = {
ok: false,
error: e instanceof Error ? e.message : 'ads failed',
};
}
try {
if (want('organico') || want('metrics')) {
const oauth = await getAgencyOAuthStatus();
if (!oauth.connected) {
out.organico = { ok: false, warning: 'Google agencia no conectado' };
} else {
out.organico = {
ok: true,
...(await syncAllConnectedOrganic({ days, tenantId })),
};
}
}
} catch (e) {
out.organico = {
ok: false,
error: e instanceof Error ? e.message : 'organic failed',
};
}
try {
if (want('seguimiento')) {
if (!superhumanConfigured()) {
out.seguimiento = {
ok: false,
warning: 'SUPERHUMAN_DOCS_API_KEY no configurado',
};
} else if (tenantId) {
out.seguimiento = {
ok: true,
...(await syncAllSuperhumanTenants({
tenantId,
maxEntries: 80,
})),
};
} else {
out.seguimiento = {
ok: true,
...(await syncAllSuperhumanTenants({ maxEntries: 80 })),
};
}
}
} catch (e) {
out.seguimiento = {
ok: false,
error: e instanceof Error ? e.message : 'seguimiento failed',
};
}
try {
if (want('activos')) {
const oauth = await getAgencyOAuthStatus();
if (!oauth.connected) {
out.activos = { ok: false, warning: 'Google Drive no conectado' };
} else {
let tenants: { id: string; name: string; slug: string }[] = [];
const headers = serviceHeaders();
const base = supabaseUrl();
if (headers && base) {
const tr = await fetch(
`${base}/rest/v1/tenants?select=id,name,slug&agency_followed=neq.false`,
{ headers, cache: 'no-store' }
);
if (tr.ok) tenants = (await tr.json()) as typeof tenants;
}
const { folders } = await listClientFoldersUnderClientes();
const matches = await matchFoldersToTenants(tenants, folders);
const { linked } = await applyMatches(matches);
out.activos = {
ok: true,
matched: matches.filter((m) => m.status === 'matched').length,
linked,
hint: 'Carpetas Drive re-emparejadas. Recarga Activos para ver archivos.',
};
}
}
} catch (e) {
out.activos = {
ok: false,
error: e instanceof Error ? e.message : 'activos failed',
};
}
if (want('social')) {
out.social = {
ok: true,
hint: 'Social se refresca al abrir la pestaña (Graph live + cache). Recarga la página.',
reload: true,
};
}
return NextResponse.json(out);
}

View File

@@ -0,0 +1,132 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import {
isAgencyRole,
serviceHeaders,
supabaseUrl,
userRole,
} from '@/lib/agency';
import {
ASSETS_BUCKET,
ensureAssetsBucket,
sanitizeFileName,
serviceSupabase,
} from '@/lib/assets';
import { randomUUID } from 'crypto';
export const dynamic = 'force-dynamic';
export const runtime = 'nodejs';
const MAX_LOGO = 2 * 1024 * 1024; // 2 MB
const ALLOWED = new Set([
'image/png',
'image/jpeg',
'image/webp',
'image/svg+xml',
]);
async function requireAgency() {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
return { error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) };
}
if (!isAgencyRole(userRole(user))) {
return { error: NextResponse.json({ error: 'Forbidden' }, { status: 403 }) };
}
const headers = serviceHeaders();
if (!headers) {
return {
error: NextResponse.json({ error: 'no service role' }, { status: 503 }),
};
}
return { headers };
}
/** POST multipart: tenantId + file — upload logo and set tenants.logo_url */
export async function POST(req: NextRequest) {
const gate = await requireAgency();
if ('error' in gate && gate.error) return gate.error;
const { headers } = gate as {
headers: { apikey: string; Authorization: string };
};
const form = await req.formData();
const tenantId = String(form.get('tenantId') || '').trim();
const file = form.get('file');
if (!tenantId || !(file instanceof File)) {
return NextResponse.json(
{ error: 'tenantId y file requeridos' },
{ status: 400 }
);
}
if (file.size > MAX_LOGO) {
return NextResponse.json(
{ error: 'Logo máximo 2 MB' },
{ status: 400 }
);
}
const mime = file.type || 'application/octet-stream';
if (!ALLOWED.has(mime)) {
return NextResponse.json(
{ error: 'Usa PNG, JPG, WEBP o SVG' },
{ status: 400 }
);
}
const bucketOk = await ensureAssetsBucket();
if (!bucketOk.ok) {
return NextResponse.json(
{ error: bucketOk.error || 'bucket' },
{ status: 503 }
);
}
const sb = serviceSupabase();
if (!sb) {
return NextResponse.json({ error: 'no service role' }, { status: 503 });
}
const safe = sanitizeFileName(file.name || 'logo.png');
const path = `${tenantId}/branding/${randomUUID()}_${safe}`;
const buf = Buffer.from(await file.arrayBuffer());
const { error: upErr } = await sb.storage
.from(ASSETS_BUCKET)
.upload(path, buf, { contentType: mime, upsert: true });
if (upErr) {
return NextResponse.json({ error: upErr.message }, { status: 500 });
}
// Store storage path; served via /api/branding/logo
const logoUrl = `storage:${path}`;
const patch = await fetch(
`${supabaseUrl()}/rest/v1/tenants?id=eq.${encodeURIComponent(tenantId)}`,
{
method: 'PATCH',
headers: {
...headers,
'Content-Type': 'application/json',
Prefer: 'return=representation',
},
body: JSON.stringify({ logo_url: logoUrl }),
}
);
if (!patch.ok) {
const msg = await patch.text();
return NextResponse.json(
{
error: msg.includes('logo_url')
? 'Aplicar sql/tenant_ficha.sql'
: msg.slice(0, 300),
},
{ status: 500 }
);
}
return NextResponse.json({
logoUrl,
serveUrl: `/api/branding/logo?tenantId=${encodeURIComponent(tenantId)}`,
});
}

View File

@@ -0,0 +1,170 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import {
isAgencyRole,
serviceHeaders,
supabaseUrl,
userRole,
} from '@/lib/agency';
import type { TenantSettings } from '@/lib/tenant-modules';
export const dynamic = 'force-dynamic';
async function requireAgency() {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
return { error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) };
}
if (!isAgencyRole(userRole(user))) {
return { error: NextResponse.json({ error: 'Forbidden' }, { status: 403 }) };
}
const headers = serviceHeaders();
if (!headers) {
return {
error: NextResponse.json({ error: 'no service role' }, { status: 503 }),
};
}
return { headers };
}
/** GET ?tenantId= — full ficha payload */
export async function GET(req: NextRequest) {
const gate = await requireAgency();
if ('error' in gate && gate.error) return gate.error;
const { headers } = gate as {
headers: { apikey: string; Authorization: string };
};
const tenantId = req.nextUrl.searchParams.get('tenantId')?.trim() || '';
if (!tenantId) {
return NextResponse.json({ error: 'tenantId requerido' }, { status: 400 });
}
const base = supabaseUrl();
const [tRes, iRes] = await Promise.all([
fetch(
`${base}/rest/v1/tenants?id=eq.${encodeURIComponent(tenantId)}&select=id,name,slug,logo_url,settings,agency_followed,agency_notes,created_at`,
{ headers, cache: 'no-store' }
),
fetch(
`${base}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(tenantId)}&select=provider,status,enabled,external_account_id,label,last_sync_at,error_message,meta`,
{ headers, cache: 'no-store' }
),
]);
if (!tRes.ok) {
const msg = await tRes.text();
return NextResponse.json(
{
error: msg.includes('logo_url') || msg.includes('settings')
? 'Aplicar sql/tenant_ficha.sql'
: msg.slice(0, 300),
},
{ status: 500 }
);
}
const tenants = await tRes.json();
const tenant = tenants[0];
if (!tenant) {
return NextResponse.json({ error: 'Cliente no encontrado' }, { status: 404 });
}
let integrations: unknown[] = [];
let integWarning: string | null = null;
if (iRes.ok) {
integrations = await iRes.json();
} else {
integWarning = (await iRes.text()).slice(0, 200);
}
return NextResponse.json({
tenant: {
id: tenant.id,
name: tenant.name,
slug: tenant.slug,
logoUrl: tenant.logo_url || null,
settings: (tenant.settings || {}) as TenantSettings,
followed: tenant.agency_followed !== false,
notes: tenant.agency_notes || '',
createdAt: tenant.created_at,
},
integrations,
warning: integWarning,
});
}
/** PATCH — follow/notes + ficha fields (name, logoUrl, settings). */
export async function PATCH(req: NextRequest) {
const gate = await requireAgency();
if ('error' in gate && gate.error) return gate.error;
const { headers } = gate as {
headers: { apikey: string; Authorization: string };
};
const body = await req.json().catch(() => ({}));
const tenantId =
typeof body.tenantId === 'string' ? body.tenantId.trim() : '';
if (!tenantId) {
return NextResponse.json({ error: 'tenantId requerido' }, { status: 400 });
}
const patch: Record<string, unknown> = {};
if (typeof body.followed === 'boolean') {
patch.agency_followed = body.followed;
}
if (typeof body.notes === 'string') {
patch.agency_notes = body.notes.trim() || null;
}
if (typeof body.name === 'string' && body.name.trim()) {
patch.name = body.name.trim();
}
if (typeof body.logoUrl === 'string') {
patch.logo_url = body.logoUrl.trim() || null;
}
if (body.logoUrl === null) {
patch.logo_url = null;
}
if (body.settings && typeof body.settings === 'object') {
patch.settings = body.settings as TenantSettings;
}
if (!Object.keys(patch).length) {
return NextResponse.json(
{ error: 'Nada que actualizar' },
{ status: 400 }
);
}
const r = await fetch(
`${supabaseUrl()}/rest/v1/tenants?id=eq.${encodeURIComponent(tenantId)}`,
{
method: 'PATCH',
headers: {
...headers,
'Content-Type': 'application/json',
Prefer: 'return=representation',
},
body: JSON.stringify(patch),
}
);
if (!r.ok) {
const msg = await r.text();
return NextResponse.json(
{
error:
msg.includes('logo_url') || msg.includes('settings')
? 'Aplicar sql/tenant_ficha.sql en Supabase'
: msg.includes('agency_followed')
? 'Aplicar sql/agency_control.sql'
: msg.slice(0, 300),
},
{ status: 500 }
);
}
const rows = await r.json();
return NextResponse.json({ tenant: rows[0] || rows });
}

View File

@@ -0,0 +1,257 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { createClient as createServiceClient } from '@supabase/supabase-js';
import {
isAgencyRole,
serviceHeaders,
supabaseUrl,
userRole,
} from '@/lib/agency';
export const dynamic = 'force-dynamic';
async function requireAgency() {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
return { error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) };
}
if (!isAgencyRole(userRole(user))) {
return { error: NextResponse.json({ error: 'Forbidden' }, { status: 403 }) };
}
const key = process.env.SUPABASE_SERVICE_ROLE_KEY?.trim();
if (!key) {
return {
error: NextResponse.json({ error: 'no service role' }, { status: 503 }),
};
}
const admin = createServiceClient(supabaseUrl(), key, {
auth: { persistSession: false, autoRefreshToken: false },
});
return { admin, headers: serviceHeaders()! };
}
function tenantOf(u: {
app_metadata?: Record<string, unknown>;
user_metadata?: Record<string, unknown>;
}): string | null {
return (
(u.app_metadata?.tenant_id as string) ||
(u.user_metadata?.tenant_id as string) ||
null
);
}
/** GET ?tenantId= — users with access to this client portal. */
export async function GET(req: NextRequest) {
const gate = await requireAgency();
if ('error' in gate && gate.error) return gate.error;
const { admin } = gate as Awaited<ReturnType<typeof requireAgency>> & {
admin: ReturnType<typeof createServiceClient>;
};
const tenantId = req.nextUrl.searchParams.get('tenantId')?.trim() || '';
if (!tenantId) {
return NextResponse.json({ error: 'tenantId requerido' }, { status: 400 });
}
const users: {
id: string;
email: string;
role: string;
createdAt: string;
lastSignIn: string | null;
}[] = [];
let page = 1;
for (;;) {
const { data, error } = await admin.auth.admin.listUsers({
page,
perPage: 200,
});
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
for (const u of data.users) {
if (tenantOf(u) !== tenantId) continue;
users.push({
id: u.id,
email: u.email || '',
role:
(u.app_metadata?.role as string) ||
(u.user_metadata?.role as string) ||
'client',
createdAt: u.created_at,
lastSignIn: u.last_sign_in_at || null,
});
}
if (data.users.length < 200) break;
page += 1;
if (page > 20) break;
}
users.sort((a, b) => a.email.localeCompare(b.email));
return NextResponse.json({ users });
}
/**
* POST — invite email to client portal view.
* Body: { tenantId, email, companyName? }
*/
export async function POST(req: NextRequest) {
const gate = await requireAgency();
if ('error' in gate && gate.error) return gate.error;
const { admin, headers } = gate as Awaited<
ReturnType<typeof requireAgency>
> & {
admin: ReturnType<typeof createServiceClient>;
headers: { apikey: string; Authorization: string };
};
const body = await req.json().catch(() => ({}));
const tenantId =
typeof body.tenantId === 'string' ? body.tenantId.trim() : '';
const email =
typeof body.email === 'string' ? body.email.toLowerCase().trim() : '';
if (!tenantId || !email || !email.includes('@')) {
return NextResponse.json(
{ error: 'tenantId y email válidos requeridos' },
{ status: 400 }
);
}
let company =
typeof body.companyName === 'string' ? body.companyName.trim() : '';
if (!company) {
const tr = await fetch(
`${supabaseUrl()}/rest/v1/tenants?id=eq.${encodeURIComponent(tenantId)}&select=name`,
{ headers, cache: 'no-store' }
);
if (tr.ok) {
const rows = await tr.json();
company = rows[0]?.name || 'Cliente';
} else {
company = 'Cliente';
}
}
const site =
process.env.NEXT_PUBLIC_SITE_URL?.trim() || 'https://app.4ventures.es';
const { data: invited, error: inviteErr } =
await admin.auth.admin.inviteUserByEmail(email, {
redirectTo: `${site}/login`,
data: {
tenant_id: tenantId,
role: 'client',
company,
},
});
if (inviteErr) {
// Fallback: create user with temp password if invite not configured
if (
inviteErr.message.toLowerCase().includes('sending') ||
inviteErr.message.toLowerCase().includes('smtp') ||
inviteErr.message.toLowerCase().includes('email')
) {
const temp =
typeof body.password === 'string' && body.password.length >= 8
? body.password
: `4V-${Math.random().toString(36).slice(2, 10)}!aA1`;
const { data: created, error: createErr } =
await admin.auth.admin.createUser({
email,
password: temp,
email_confirm: true,
user_metadata: {
tenant_id: tenantId,
role: 'client',
company,
},
app_metadata: {
tenant_id: tenantId,
role: 'client',
company,
},
});
if (createErr) {
return NextResponse.json(
{ error: createErr.message },
{ status: 500 }
);
}
return NextResponse.json({
user: {
id: created.user?.id,
email,
role: 'client',
},
invited: false,
temporaryPassword: temp,
hint: 'SMTP no configurado: se creó usuario con contraseña temporal. Compártela al cliente.',
});
}
return NextResponse.json({ error: inviteErr.message }, { status: 500 });
}
const uid = invited.user?.id;
if (uid) {
await admin.auth.admin.updateUserById(uid, {
app_metadata: {
tenant_id: tenantId,
role: 'client',
company,
},
user_metadata: {
tenant_id: tenantId,
role: 'client',
company,
},
});
}
return NextResponse.json({
user: { id: uid, email, role: 'client' },
invited: true,
});
}
/** DELETE — remove portal access (delete auth user). Body: { tenantId, userId } */
export async function DELETE(req: NextRequest) {
const gate = await requireAgency();
if ('error' in gate && gate.error) return gate.error;
const { admin } = gate as Awaited<ReturnType<typeof requireAgency>> & {
admin: ReturnType<typeof createServiceClient>;
};
const body = await req.json().catch(() => ({}));
const tenantId =
typeof body.tenantId === 'string' ? body.tenantId.trim() : '';
const userId = typeof body.userId === 'string' ? body.userId.trim() : '';
if (!tenantId || !userId) {
return NextResponse.json(
{ error: 'tenantId y userId requeridos' },
{ status: 400 }
);
}
const { data, error } = await admin.auth.admin.getUserById(userId);
if (error || !data.user) {
return NextResponse.json({ error: 'Usuario no encontrado' }, { status: 404 });
}
if (tenantOf(data.user) !== tenantId) {
return NextResponse.json(
{ error: 'El usuario no pertenece a este cliente' },
{ status: 403 }
);
}
const del = await admin.auth.admin.deleteUser(userId);
if (del.error) {
return NextResponse.json({ error: del.error.message }, { status: 500 });
}
return NextResponse.json({ ok: true });
}

View File

@@ -0,0 +1,135 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { resolveActiveTenant } from '@/lib/tenant-context';
import {
dualStatus,
insertComment,
listApprovals,
listComments,
partyFromUser,
upsertApproval,
} from '@/lib/content-collab';
import { serviceHeaders, supabaseUrl } from '@/lib/agency';
export const dynamic = 'force-dynamic';
async function gate() {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
return { error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) };
}
const ctx = await resolveActiveTenant(user);
if (!ctx.tenantId || ctx.portfolioWide) {
return {
error: NextResponse.json({ error: 'Selecciona un cliente' }, { status: 400 }),
};
}
return { user, ctx, party: partyFromUser(user) };
}
export async function GET(req: NextRequest) {
const g = await gate();
if ('error' in g && g.error) return g.error;
const { ctx, party } = g as {
ctx: { tenantId: string };
party: 'agency' | 'client';
};
const ids = (req.nextUrl.searchParams.get('ids') || '')
.split(',')
.map((s) => s.trim())
.filter(Boolean);
const resourceId = req.nextUrl.searchParams.get('resourceId');
const approvals = await listApprovals(ctx.tenantId, 'asset', ids);
const dual: Record<string, ReturnType<typeof dualStatus>> = {};
for (const id of ids) dual[id] = dualStatus(approvals[id] || []);
let comments = null;
if (resourceId) {
comments = await listComments(ctx.tenantId, 'asset', resourceId);
}
return NextResponse.json({ dual, comments, party });
}
export async function POST(req: NextRequest) {
const g = await gate();
if ('error' in g && g.error) return g.error;
const { user, ctx, party } = g as {
user: { id: string; email?: string };
ctx: { tenantId: string };
party: 'agency' | 'client';
};
const body = await req.json().catch(() => ({}));
const assetId = String(body.assetId || '');
if (!assetId) {
return NextResponse.json({ error: 'assetId requerido' }, { status: 400 });
}
if (body.action === 'approve' || body.action === 'revoke') {
await upsertApproval({
tenantId: ctx.tenantId,
resourceType: 'asset',
resourceId: assetId,
party,
approved: body.action === 'approve',
actorId: user.id,
actorEmail: user.email || null,
});
const approvals = await listApprovals(ctx.tenantId, 'asset', [assetId]);
const dual = dualStatus(approvals[assetId] || []);
// Mirror to asset_files columns when both OK
const headers = serviceHeaders();
if (headers) {
const patch: Record<string, unknown> = {
updated_at: new Date().toISOString(),
};
if (party === 'agency') {
patch.agency_approved_at =
body.action === 'approve' ? new Date().toISOString() : null;
patch.agency_approved_by = body.action === 'approve' ? user.id : null;
} else {
patch.client_approved_at =
body.action === 'approve' ? new Date().toISOString() : null;
patch.client_approved_by = body.action === 'approve' ? user.id : null;
}
if (dual.both) patch.review_status = 'validated';
else patch.review_status = 'in_review';
await fetch(
`${supabaseUrl()}/rest/v1/asset_files?id=eq.${encodeURIComponent(assetId)}&tenant_id=eq.${encodeURIComponent(ctx.tenantId)}`,
{
method: 'PATCH',
headers: {
...headers,
'Content-Type': 'application/json',
Prefer: 'return=minimal',
},
body: JSON.stringify(patch),
}
);
}
return NextResponse.json({ ok: true, dual, party });
}
if (body.action === 'comment') {
const text = String(body.body || '').trim();
if (!text) {
return NextResponse.json({ error: 'Comentario vacío' }, { status: 400 });
}
const row = await insertComment({
tenantId: ctx.tenantId,
resourceType: 'asset',
resourceId: assetId,
body: text.slice(0, 4000),
authorId: user.id,
authorEmail: user.email || null,
authorParty: party,
});
return NextResponse.json({ ok: true, comment: row });
}
return NextResponse.json({ error: 'action inválida' }, { status: 400 });
}

View File

@@ -0,0 +1,77 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import {
isAgencyRole,
serviceHeaders,
supabaseUrl,
userRole,
} from '@/lib/agency';
import { resolveActiveTenant } from '@/lib/tenant-context';
import { ASSETS_BUCKET, serviceSupabase } from '@/lib/assets';
export const dynamic = 'force-dynamic';
/** GET ?id= — signed download URL (60s) */
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 });
}
const ctx = await resolveActiveTenant(user);
if (!ctx.tenantId) {
return NextResponse.json({ error: 'Selecciona un cliente' }, { status: 400 });
}
const id = req.nextUrl.searchParams.get('id')?.trim();
if (!id) {
return NextResponse.json({ error: 'id requerido' }, { status: 400 });
}
const headers = serviceHeaders();
const svc = serviceSupabase();
if (!headers || !svc) {
return NextResponse.json({ error: 'no service role' }, { status: 503 });
}
// Agency can download any tenant's file when in context; verify tenant match
const r = await fetch(
`${supabaseUrl()}/rest/v1/asset_files?id=eq.${encodeURIComponent(id)}&select=id,tenant_id,storage_path,file_name,mime_type`,
{ headers, cache: 'no-store' }
);
if (!r.ok) {
return NextResponse.json({ error: await r.text() }, { status: 500 });
}
const rows = await r.json();
const row = rows[0];
if (!row) {
return NextResponse.json({ error: 'No encontrado' }, { status: 404 });
}
const agency = isAgencyRole(userRole(user));
if (!agency && row.tenant_id !== ctx.tenantId) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
if (agency && row.tenant_id !== ctx.tenantId) {
return NextResponse.json({ error: 'Cambia al tenant del archivo' }, { status: 403 });
}
const signed = await svc.storage
.from(ASSETS_BUCKET)
.createSignedUrl(row.storage_path, 120);
if (signed.error || !signed.data?.signedUrl) {
return NextResponse.json(
{ error: signed.error?.message || 'No se pudo firmar URL' },
{ status: 500 }
);
}
return NextResponse.json({
url: signed.data.signedUrl,
fileName: row.file_name,
mimeType: row.mime_type,
});
}

View File

@@ -0,0 +1,148 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { isAgencyRole, userRole } from '@/lib/agency';
import { resolveActiveTenant } from '@/lib/tenant-context';
import {
findClientesRootFolder,
getDriveDownloadUrl,
getDriveFileMeta,
getTenantDriveFolderId,
isUnderDriveFolder,
listFilesInFolder,
} from '@/lib/google-drive';
export const dynamic = 'force-dynamic';
const FOLDER_MIME = 'application/vnd.google-apps.folder';
/** GET — exact replica of the tenant's Drive folder tree (under Clientes). */
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 });
}
const ctx = await resolveActiveTenant(user);
if (!ctx.tenantId || ctx.portfolioWide) {
return NextResponse.json(
{ error: 'Selecciona un cliente en Contexto' },
{ status: 400 }
);
}
void isAgencyRole(userRole(user));
const downloadId = req.nextUrl.searchParams.get('download')?.trim();
if (downloadId) {
try {
const link = await getTenantDriveFolderId(ctx.tenantId);
if (!link) {
return NextResponse.json(
{ error: 'Sin carpeta Drive del cliente' },
{ status: 400 }
);
}
const ok = await isUnderDriveFolder(downloadId, link.folderId);
if (!ok) {
return NextResponse.json(
{ error: 'Archivo fuera de la carpeta del cliente' },
{ status: 403 }
);
}
const url = await getDriveDownloadUrl(downloadId);
return NextResponse.json({ url });
} catch (e) {
return NextResponse.json(
{ error: e instanceof Error ? e.message : 'download failed' },
{ status: 500 }
);
}
}
const link = await getTenantDriveFolderId(ctx.tenantId);
if (!link) {
return NextResponse.json({
files: [],
linked: false,
warning:
'Sin carpeta Drive del cliente. En Agencia → auto-match bajo la carpeta «Clientes».',
});
}
// Soft check: linked folder should live under «Clientes»
let scopeWarning: string | null = null;
try {
const clientes = await findClientesRootFolder();
if (clientes) {
const ok = await isUnderDriveFolder(link.folderId, clientes.id);
if (!ok) {
scopeWarning = `La carpeta enlazada («${link.folderName || link.folderId}») no está dentro de «Clientes». Rematch en Agencia.`;
}
}
} catch {
/* ignore */
}
const requested = req.nextUrl.searchParams.get('folderId')?.trim();
let folderId = link.folderId;
if (requested && requested !== link.folderId) {
const allowed = await isUnderDriveFolder(requested, link.folderId);
if (!allowed) {
return NextResponse.json(
{
error:
'Carpeta fuera del árbol del cliente. Solo se permite navegar dentro de su carpeta en Clientes.',
rootFolderId: link.folderId,
folderName: link.folderName,
},
{ status: 403 }
);
}
folderId = requested;
}
try {
const currentMeta = await getDriveFileMeta(folderId);
const files = await listFilesInFolder(folderId);
const mapped = files
.map((f) => ({
id: f.id,
name: f.name,
mimeType: f.mimeType,
bytes: f.size ? Number(f.size) : null,
modifiedTime: f.modifiedTime,
webViewLink: f.webViewLink,
isFolder: f.mimeType === FOLDER_MIME,
source: 'drive' as const,
}))
.sort((a, b) => {
if (a.isFolder !== b.isFolder) return a.isFolder ? -1 : 1;
return a.name.localeCompare(b.name, 'es');
});
return NextResponse.json({
linked: true,
rootFolderId: link.folderId,
rootFolderName: link.folderName,
folderId,
folderName: currentMeta?.name || link.folderName,
driveOpenUrl: `https://drive.google.com/drive/folders/${encodeURIComponent(folderId)}`,
files: mapped,
warning: scopeWarning,
});
} catch (e) {
return NextResponse.json({
linked: true,
rootFolderId: link.folderId,
rootFolderName: link.folderName,
folderId,
folderName: link.folderName,
files: [],
warning: scopeWarning || (e instanceof Error ? e.message : 'Drive error'),
});
}
}

Some files were not shown because too many files have changed in this diff Show More