Compare commits

...

87 Commits

Author SHA1 Message Date
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
176 changed files with 35316 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,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,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())

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,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,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,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'),
});
}
}

274
src/app/api/assets/route.ts Normal file
View File

@@ -0,0 +1,274 @@
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,
MAX_ASSET_BYTES,
ensureAssetsBucket,
sanitizeFileName,
sanitizeFolder,
serviceSupabase,
} from '@/lib/assets';
import {
ensureChildFolder,
getTenantDriveFolderId,
uploadToDrive,
} from '@/lib/google-drive';
import { createHash, randomUUID } from 'crypto';
export const dynamic = 'force-dynamic';
export const runtime = 'nodejs';
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.portfolioWide || !ctx.tenantId) {
return {
error: NextResponse.json(
{ error: 'Selecciona un cliente en Contexto antes de gestionar activos.' },
{ status: 400 }
),
};
}
return { user, ctx, agency: isAgencyRole(userRole(user)) };
}
/** GET — list approved files for active tenant */
export async function GET() {
const g = await gate();
if ('error' in g && g.error) return g.error;
const { ctx, agency } = g as Awaited<ReturnType<typeof gate>> & {
ctx: { tenantId: string };
agency: boolean;
};
const headers = serviceHeaders();
if (agency && headers) {
const r = await fetch(
`${supabaseUrl()}/rest/v1/asset_files?tenant_id=eq.${encodeURIComponent(ctx.tenantId!)}&status=eq.approved&select=*&order=created_at.desc`,
{ headers, cache: 'no-store' }
);
if (!r.ok) {
const msg = await r.text();
return NextResponse.json({
assets: [],
warning: msg.includes('does not exist')
? 'Aplicar sql/asset_files.sql'
: msg.slice(0, 200),
});
}
return NextResponse.json({ assets: await r.json() });
}
const supabase = await createClient();
const { data, error } = await supabase
.from('asset_files')
.select('*')
.eq('status', 'approved')
.order('created_at', { ascending: false });
if (error) {
return NextResponse.json({
assets: [],
warning: error.message,
});
}
return NextResponse.json({ assets: data ?? [] });
}
/** POST multipart: files[] + optional folder */
export async function POST(req: NextRequest) {
const g = await gate();
if ('error' in g && g.error) return g.error;
const { user, ctx } = g as Awaited<ReturnType<typeof gate>> & {
user: { id: string };
ctx: { tenantId: string };
};
const bucketOk = await ensureAssetsBucket();
if (!bucketOk.ok) {
return NextResponse.json(
{ error: `Storage bucket: ${bucketOk.error}` },
{ status: 503 }
);
}
const svc = serviceSupabase();
const headers = serviceHeaders();
if (!svc || !headers) {
return NextResponse.json(
{ error: 'Falta SUPABASE_SERVICE_ROLE_KEY' },
{ status: 503 }
);
}
const form = await req.formData();
const folder = sanitizeFolder(String(form.get('folder') || ''));
const files = form.getAll('files').filter((f): f is File => f instanceof File);
if (!files.length) {
return NextResponse.json({ error: 'Ningún archivo' }, { status: 400 });
}
const uploaded: unknown[] = [];
const errors: string[] = [];
for (const file of files) {
if (file.size <= 0) {
errors.push(`${file.name}: vacío`);
continue;
}
if (file.size > MAX_ASSET_BYTES) {
errors.push(`${file.name}: supera 80 MB`);
continue;
}
const safeName = sanitizeFileName(file.name);
const id = randomUUID();
const pathParts = [ctx.tenantId!, folder, `${id}_${safeName}`].filter(
Boolean
);
const storagePath = pathParts.join('/');
const buf = Buffer.from(await file.arrayBuffer());
const sha256 = createHash('sha256').update(buf).digest('hex');
const up = await svc.storage.from(ASSETS_BUCKET).upload(storagePath, buf, {
contentType: file.type || 'application/octet-stream',
upsert: false,
});
if (up.error) {
errors.push(`${file.name}: ${up.error.message}`);
continue;
}
const row = {
id,
tenant_id: ctx.tenantId,
title: safeName,
folder,
file_name: safeName,
mime_type: file.type || 'application/octet-stream',
bytes: file.size,
storage_path: storagePath,
sha256,
status: 'approved',
created_by: user.id,
};
const ins = await fetch(`${supabaseUrl()}/rest/v1/asset_files`, {
method: 'POST',
headers: {
...headers,
'Content-Type': 'application/json',
Prefer: 'return=representation',
},
body: JSON.stringify(row),
});
if (!ins.ok) {
await svc.storage.from(ASSETS_BUCKET).remove([storagePath]);
errors.push(`${file.name}: ${await ins.text()}`);
continue;
}
const created = await ins.json();
const assetRow = Array.isArray(created) ? created[0] : created;
// Best-effort mirror to linked Google Drive folder
try {
const link = await getTenantDriveFolderId(ctx.tenantId!);
if (link) {
let parentId = link.folderId;
if (folder) {
const parts = folder.split('/').filter(Boolean);
for (const part of parts) {
parentId = await ensureChildFolder(parentId, part);
}
}
const driveFile = await uploadToDrive({
parentFolderId: parentId,
fileName: safeName,
mimeType: file.type || 'application/octet-stream',
buffer: buf,
});
if (driveFile.id && assetRow?.id) {
await fetch(
`${supabaseUrl()}/rest/v1/asset_files?id=eq.${encodeURIComponent(assetRow.id)}`,
{
method: 'PATCH',
headers: {
...headers,
'Content-Type': 'application/json',
},
body: JSON.stringify({ drive_file_id: driveFile.id }),
}
);
assetRow.drive_file_id = driveFile.id;
}
}
} catch (e) {
errors.push(
`${file.name}: portal ok, Drive: ${e instanceof Error ? e.message : 'error'}`
);
}
uploaded.push(assetRow);
}
return NextResponse.json(
{ uploaded, errors },
{ status: uploaded.length ? 201 : 400 }
);
}
/** DELETE ?id= */
export async function DELETE(req: NextRequest) {
const g = await gate();
if ('error' in g && g.error) return g.error;
const { ctx } = g as Awaited<ReturnType<typeof gate>> & {
ctx: { tenantId: string };
};
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 });
}
const get = await fetch(
`${supabaseUrl()}/rest/v1/asset_files?id=eq.${encodeURIComponent(id)}&tenant_id=eq.${encodeURIComponent(ctx.tenantId!)}&select=id,storage_path`,
{ headers, cache: 'no-store' }
);
if (!get.ok) {
return NextResponse.json({ error: await get.text() }, { status: 500 });
}
const rows = await get.json();
const row = rows[0];
if (!row) {
return NextResponse.json({ error: 'No encontrado' }, { status: 404 });
}
await svc.storage.from(ASSETS_BUCKET).remove([row.storage_path]);
const del = await fetch(
`${supabaseUrl()}/rest/v1/asset_files?id=eq.${encodeURIComponent(id)}`,
{ method: 'DELETE', headers }
);
if (!del.ok) {
return NextResponse.json({ error: await del.text() }, { status: 500 });
}
return NextResponse.json({ ok: true });
}

View File

@@ -0,0 +1,97 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import {
homeTenantId,
isAgencyRole,
serviceHeaders,
supabaseUrl,
userRole,
VIEW_TENANT_COOKIE,
} from '@/lib/agency';
import { ASSETS_BUCKET, serviceSupabase } from '@/lib/assets';
import { cookies } from 'next/headers';
export const dynamic = 'force-dynamic';
export const runtime = 'nodejs';
/**
* GET ?tenantId= — stream client logo (agency any tenant; client only own).
* logo_url may be https://… or storage:{path}.
*/
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 agency = isAgencyRole(userRole(user));
const cookieStore = await cookies();
const viewId = cookieStore.get(VIEW_TENANT_COOKIE)?.value || null;
const paramId = req.nextUrl.searchParams.get('tenantId')?.trim() || '';
const ownId = homeTenantId(user);
let tenantId = paramId || viewId || ownId || '';
if (!tenantId) {
return NextResponse.json({ error: 'sin tenant' }, { status: 400 });
}
if (!agency && tenantId !== ownId) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const headers = serviceHeaders();
if (!headers) {
return NextResponse.json({ error: 'no service role' }, { status: 503 });
}
const tr = await fetch(
`${supabaseUrl()}/rest/v1/tenants?id=eq.${encodeURIComponent(tenantId)}&select=logo_url`,
{ headers, cache: 'no-store' }
);
if (!tr.ok) {
return NextResponse.json({ error: 'tenant' }, { status: 404 });
}
const rows = await tr.json();
const logoUrl: string | null = rows[0]?.logo_url || null;
if (!logoUrl) {
return NextResponse.json({ error: 'sin logo' }, { status: 404 });
}
if (logoUrl.startsWith('http://') || logoUrl.startsWith('https://')) {
return NextResponse.redirect(logoUrl);
}
const path = logoUrl.startsWith('storage:')
? logoUrl.slice('storage:'.length)
: logoUrl;
const sb = serviceSupabase();
if (!sb) {
return NextResponse.json({ error: 'no service role' }, { status: 503 });
}
const { data, error } = await sb.storage.from(ASSETS_BUCKET).download(path);
if (error || !data) {
return NextResponse.json(
{ error: error?.message || 'not found' },
{ status: 404 }
);
}
const buf = Buffer.from(await data.arrayBuffer());
const ext = path.split('.').pop()?.toLowerCase();
const type =
ext === 'png'
? 'image/png'
: ext === 'webp'
? 'image/webp'
: ext === 'svg'
? 'image/svg+xml'
: 'image/jpeg';
return new NextResponse(buf, {
headers: {
'Content-Type': type,
'Cache-Control': 'private, max-age=300',
},
});
}

View File

@@ -0,0 +1,127 @@
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 { isAgencyRole, userRole } 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 para colaborar en el calendario.' },
{ status: 400 }
),
};
}
return { user, ctx, party: partyFromUser(user) };
}
/** GET ?ids=a,b,c — approvals map; &resourceId=x — also comments */
export async function GET(req: NextRequest) {
const g = await gate();
if ('error' in g && g.error) return g.error;
const { ctx } = g as Awaited<ReturnType<typeof gate>> & {
ctx: { tenantId: string };
};
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!, 'calendar_post', 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!, 'calendar_post', resourceId);
}
return NextResponse.json({
approvals,
dual,
comments,
party: (g as { party: string }).party,
isAgency: isAgencyRole(userRole((g as { user: unknown }).user as never)),
});
}
/**
* POST actions:
* { action: 'approve' | 'revoke', postId, note? }
* { action: 'comment', postId, body }
*/
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 postId = String(body.postId || '');
if (!postId) {
return NextResponse.json({ error: 'postId requerido' }, { status: 400 });
}
if (body.action === 'approve' || body.action === 'revoke') {
await upsertApproval({
tenantId: ctx.tenantId,
resourceType: 'calendar_post',
resourceId: postId,
party,
approved: body.action === 'approve',
actorId: user.id,
actorEmail: user.email || null,
note: body.note ? String(body.note) : null,
});
const approvals = await listApprovals(ctx.tenantId, 'calendar_post', [postId]);
return NextResponse.json({
ok: true,
dual: dualStatus(approvals[postId] || []),
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: 'calendar_post',
resourceId: postId,
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,84 @@
import { NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { resolveActiveTenant, restGet } from '@/lib/tenant-context';
import { isAgencyRole, serviceHeaders, supabaseUrl, userRole } from '@/lib/agency';
import octPlan from '@/data/rrss-octubre-2026.json';
export const dynamic = 'force-dynamic';
/**
* POST — publish bundled RRSS month plan into documents for the active tenant.
* Agency only. Idempotent upsert by title + type.
*/
export async function POST() {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
if (!isAgencyRole(userRole(user))) {
return NextResponse.json({ error: 'Solo agencia' }, { status: 403 });
}
const ctx = await resolveActiveTenant(user);
if (!ctx.tenantId || ctx.portfolioWide) {
return NextResponse.json(
{ error: 'Selecciona un cliente en Contexto' },
{ status: 400 }
);
}
const headers = serviceHeaders();
if (!headers) {
return NextResponse.json({ error: 'no service role' }, { status: 500 });
}
const title = `RRSS ${octPlan.label}`;
const tid = encodeURIComponent(ctx.tenantId);
const existing = await restGet<Array<{ id: string }>>(
`/rest/v1/documents?tenant_id=eq.${tid}&type=eq.calendar&title=eq.${encodeURIComponent(title)}&select=id&limit=1`
);
const payload = {
tenant_id: ctx.tenantId,
type: 'calendar',
title,
content: octPlan,
};
let res: Response;
if (existing.data?.[0]?.id) {
res = await fetch(
`${supabaseUrl()}/rest/v1/documents?id=eq.${existing.data[0].id}`,
{
method: 'PATCH',
headers: { ...headers, 'Content-Type': 'application/json', Prefer: 'return=representation' },
body: JSON.stringify({ content: octPlan, title }),
}
);
} else {
res = await fetch(`${supabaseUrl()}/rest/v1/documents`, {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json', Prefer: 'return=representation' },
body: JSON.stringify(payload),
});
}
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: 502 }
);
}
const row = await res.json();
return NextResponse.json({
ok: true,
tenant: ctx.label,
month: octPlan.month,
pieces: (octPlan.pieces || []).length,
document: Array.isArray(row) ? row[0] : row,
});
}

View File

@@ -1,5 +1,12 @@
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, KpiWeeklyRow } from '@/lib/metrics';
import {
SimChannel,
simulateFromTenantMetrics,
} from '@/lib/simulate';
export async function POST(req: NextRequest) {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || 'https://supabase.4ventures.es';
@@ -12,10 +19,12 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const tenantId =
const ctx = await resolveActiveTenant(user);
const agency = isAgencyRole(userRole(user));
const tenantId = ctx.tenantId ||
user.app_metadata?.tenant_id || user.user_metadata?.tenant_id || null;
const companyName = user.user_metadata?.company || 'Cliente';
if (!tenantId) {
const companyName = ctx.label || user.user_metadata?.company || 'Cliente';
if (!tenantId && !ctx.portfolioWide) {
return NextResponse.json(
{ error: 'Usuario sin tenant_id en metadata. Re-provisionar cliente.' },
{ status: 403 }
@@ -121,52 +130,85 @@ export async function POST(req: NextRequest) {
normQuery.includes('presupuesto')
) {
try {
const matchNumber = query.match(/(\d+[\d\.,]*)/);
if (ctx.portfolioWide || !tenantId) {
return reply(
'Selecciona un **cliente en Contexto** para simular con sus métricas reales (CPL, cierre y ticket).'
);
}
const matchNumber = userMessage.match(/(\d+[\d\.,]*)/);
let amount = 1000;
if (matchNumber) {
amount = parseFloat(matchNumber[1].replace(/\./g, '').replace(',', '.'));
}
let channel: string | null = null;
let channel: SimChannel = 'blended';
if (normQuery.includes('meta') || normQuery.includes('facebook') || normQuery.includes('instagram')) channel = 'meta';
else if (normQuery.includes('google')) channel = 'google';
else if (normQuery.includes('tiktok')) channel = 'tiktok';
else if (normQuery.includes('linkedin')) channel = 'linkedin';
const res = await fetch(`${supabaseUrl}/rest/v1/rpc/simulate_budget`, {
method: 'POST',
headers,
body: JSON.stringify({
p_tenant_id: tenantId,
p_additional_spend: amount,
p_target_channel: channel
})
});
if (res.ok) {
const sim = await res.json();
const p10 = sim.conservative_p10 || {};
const p50 = sim.expected_p50 || {};
const p90 = sim.optimistic_p90 || {};
const base = sim.baseline || {};
const channelLabel = (sim.target_channel || 'General').toUpperCase();
const text =
`🔮 **Simulación de Inversión Adicional (+${amount.toLocaleString('es-ES')} € en ${channelLabel}):**\n\n` +
`📈 **Métricas Base Observadas:**\n` +
`- **CPL Histórico:** ${Number(base.historical_cpl || 0).toFixed(2)} €\n` +
`- **Tasa de Cierre:** ${Number(base.conversion_rate_pct || 0).toFixed(1)}%\n` +
`- **Ticket Medio:** ${Number(base.avg_ticket || 0).toLocaleString('es-ES', { minimumFractionDigits: 2 })} €\n\n` +
`📊 **Proyección por Escenarios Matemáticos:**\n` +
`- **Escenario Esperado (p50):** +${p50.leads} leads | +${p50.deals} clientes | **+${Number(p50.revenue || 0).toLocaleString('es-ES')} €** facturación (ROAS **${p50.roas}x**)\n` +
`- **Escenario Conservador (p10):** +${p10.leads} leads | +${p10.deals} clientes | **+${Number(p10.revenue || 0).toLocaleString('es-ES')} €** facturación (ROAS **${p10.roas}x**)\n` +
`- **Escenario Optimista (p90):** +${p90.leads} leads | +${p90.deals} clientes | **+${Number(p90.revenue || 0).toLocaleString('es-ES')} €** facturación (ROAS **${p90.roas}x**)\n\n` +
`💡 *Cálculo determinista con la función RPC simulate_budget basada en la atribución de cohorte a 90 días.*`;
return reply(text);
else if (normQuery.includes('tiktok')) {
return reply(
'TikTok aún no tiene campañas activas en este portafolio. Puedo simular con **Meta**, **Google** o presupuesto **blended** según el histórico del cliente.'
);
}
let daily: AdDailyRow[] = [];
let weekly: KpiWeeklyRow[] = [];
if (agency && ctx.viaView) {
const tid = encodeURIComponent(tenantId);
const [d, w] = await Promise.all([
restGet<AdDailyRow[]>(
`/rest/v1/ad_daily?tenant_id=eq.${tid}&select=*&order=date.desc&limit=400`
),
restGet<KpiWeeklyRow[]>(
`/rest/v1/kpi_weekly?tenant_id=eq.${tid}&select=*&order=week.desc&limit=52`
),
]);
daily = d.data || [];
weekly = w.data || [];
} else {
const [{ data: d }, { data: w }] = await Promise.all([
supabase
.from('ad_daily')
.select('*')
.order('date', { ascending: false })
.limit(400),
supabase
.from('kpi_weekly')
.select('*')
.order('week', { ascending: false })
.limit(52),
]);
daily = (d as AdDailyRow[]) || [];
weekly = (w as KpiWeeklyRow[]) || [];
}
const sim = simulateFromTenantMetrics({
spend: Number.isFinite(amount) && amount > 0 ? amount : 1000,
channel,
daily,
weekly,
});
const p10 = sim.conservative_p10;
const p50 = sim.expected_p50;
const p90 = sim.optimistic_p90;
const base = sim.baseline;
const channelLabel = (sim.target_channel || 'blended').toUpperCase();
const text =
`🔮 **Simulación (+${amount.toLocaleString('es-ES')} € · ${channelLabel}) — ${companyName}:**\n\n` +
`📈 **Baseline del cliente (ad_daily + kpi_weekly):**\n` +
`- **CPL histórico:** ${Number(base.historical_cpl || 0).toFixed(2)} €\n` +
`- **Tasa de cierre:** ${Number(base.conversion_rate_pct || 0).toFixed(1)}%\n` +
`- **Ticket medio:** ${Number(base.avg_ticket || 0).toLocaleString('es-ES', { minimumFractionDigits: 2 })} €\n` +
`- **Muestra:** ${Number(base.sample_spend || 0).toLocaleString('es-ES')} € · ${base.sample_leads || 0} leads\n\n` +
`📊 **Proyección:**\n` +
`- **Esperado (p50):** +${p50.leads} leads | +${p50.deals} clientes | **+${Number(p50.revenue || 0).toLocaleString('es-ES')} €** (ROAS **${p50.roas}x**)\n` +
`- **Conservador (p10):** +${p10.leads} leads | +${p10.deals} clientes | **+${Number(p10.revenue || 0).toLocaleString('es-ES')} €** (ROAS **${p10.roas}x**)\n` +
`- **Optimista (p90):** +${p90.leads} leads | +${p90.deals} clientes | **+${Number(p90.revenue || 0).toLocaleString('es-ES')} €** (ROAS **${p90.roas}x**)\n\n` +
`💡 *Estimación con métricas reales del cliente en contexto. Sin TikTok (sin campañas activas).*`;
return reply(text);
} catch (e) {
console.error('Error in simulate_budget chat:', e);
console.error('Error in tenant simulate chat:', e);
}
}
@@ -268,7 +310,7 @@ export async function POST(req: NextRequest) {
`${i + 1}. **${c.campaign_name}** (${c.platform.toUpperCase()}):\n - Inversión: **${Number(c.spend || 0).toFixed(2)} €**\n - Clics: ${c.clicks || 0} (CTR: ${c.impressions > 0 ? ((c.clicks / c.impressions) * 100).toFixed(2) : 0}%)\n - Leads Captados: **${c.leads || 0}**`
).join('\n\n');
return reply(`🏆 **Top Campañas por Inversión Publicitaria:**\n\n${list}\n\n*Fuente: Datos diarios procesados vía Windsor.ai.*`);
return reply(`🏆 **Top Campañas por Inversión Publicitaria:**\n\n${list}\n\n*Fuente: ad_daily (Meta + Google Ads sincronizados en el portal).*`);
} else {
return reply('📢 Actualmente no constan campañas activas con inversión registrada en los últimos 30 días para este portal.');
}
@@ -305,7 +347,7 @@ export async function POST(req: NextRequest) {
`📄 **${m.document_title}** (${m.document_type.toUpperCase()}):\n> ${m.chunk_text.replace(/\n/g, '\n> ')}`
).join('\n\n');
return reply(`📚 **Fragmentos Recuperados de la Biblioteca de Activos (RAG Híbrido):**\n\n${excerpts}\n\n*Búsqueda híbrida vectorial (pgvector) y BM25 en document_chunks.*`);
return reply(`📚 **Fragmentos de la Biblioteca de Activos:**\n\n${excerpts}\n\n*Fuente: document_chunks del tenant (búsqueda en portal).*`);
}
}

View File

@@ -0,0 +1,87 @@
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= — redirect to signed URL for creative media */
export async function GET(req: NextRequest) {
const id = req.nextUrl.searchParams.get('id')?.trim() || '';
if (!id) {
return NextResponse.json({ error: 'id requerido' }, { status: 400 });
}
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: 'Sin tenant' }, { status: 400 });
}
const agency = isAgencyRole(userRole(user));
const headers = serviceHeaders();
let storagePath: string | null = null;
if (agency && headers) {
const r = await fetch(
`${supabaseUrl()}/rest/v1/creatives?id=eq.${encodeURIComponent(id)}&tenant_id=eq.${encodeURIComponent(ctx.tenantId)}&select=storage_path,media_url`,
{ headers, cache: 'no-store' }
);
if (!r.ok) {
return NextResponse.json({ error: await r.text() }, { status: 502 });
}
const rows = await r.json();
storagePath = rows?.[0]?.storage_path || null;
const mediaUrl = rows?.[0]?.media_url as string | undefined;
if (!storagePath && mediaUrl && !mediaUrl.startsWith('/api/creatives/media')) {
return NextResponse.redirect(mediaUrl);
}
} else {
const { data } = await supabase
.from('creatives')
.select('storage_path,media_url')
.eq('id', id)
.maybeSingle();
storagePath = data?.storage_path || null;
if (
!storagePath &&
data?.media_url &&
!String(data.media_url).startsWith('/api/creatives/media')
) {
return NextResponse.redirect(data.media_url);
}
}
if (!storagePath) {
return NextResponse.json({ error: 'Sin media' }, { status: 404 });
}
const svc = serviceSupabase();
if (!svc) {
return NextResponse.json({ error: 'Service role required' }, { status: 503 });
}
const signed = await svc.storage
.from(ASSETS_BUCKET)
.createSignedUrl(storagePath, 300);
if (signed.error || !signed.data?.signedUrl) {
return NextResponse.json(
{ error: signed.error?.message || 'No signed URL' },
{ status: 500 }
);
}
return NextResponse.redirect(signed.data.signedUrl);
}

View File

@@ -1,5 +1,82 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { createClient } from '@/lib/supabase/server';
import {
VIEW_TENANT_COOKIE,
homeTenantId,
isAgencyRole,
serviceHeaders,
supabaseUrl,
userRole,
} from '@/lib/agency';
const SELECT =
'id,title,status,channel,placement,format,headline,primary_text,description,cta,media_url,brand_name,landing_url,storage_path,mime_type,bytes,created_at,updated_at';
const CHANNELS = new Set([
'meta_ads',
'google_ads',
'instagram',
'facebook',
'linkedin',
'tiktok',
'stories',
'other',
]);
const FORMATS = new Set([
'image',
'video',
'carousel',
'reel',
'story',
'rsa',
'pmax',
'pmax_landscape',
'pmax_square',
'pmax_portrait',
'pmax_vertical',
'pmax_logo',
'youtube',
'other',
]);
const ALLOWED_STATUS = new Set(['draft', 'in_review', 'approved', 'rejected']);
async function resolveTenantId(user: {
app_metadata?: Record<string, unknown>;
user_metadata?: Record<string, unknown>;
}): Promise<{ tenantId: string | null; viaView: boolean }> {
const role = userRole(user);
const cookieStore = await cookies();
const viewId = cookieStore.get(VIEW_TENANT_COOKIE)?.value || null;
if (isAgencyRole(role) && viewId) {
return { tenantId: viewId, viaView: true };
}
return { tenantId: homeTenantId(user), viaView: false };
}
function str(v: unknown, max = 2000): string | null {
if (typeof v !== 'string') return null;
const t = v.trim();
if (!t) return null;
return t.slice(0, max);
}
function previewFields(body: Record<string, unknown>) {
const channel = str(body.channel, 40);
const format = str(body.format, 40);
return {
channel: channel && CHANNELS.has(channel) ? channel : null,
placement: str(body.placement, 40),
format: format && FORMATS.has(format) ? format : null,
headline: str(body.headline, 500),
primary_text: str(body.primary_text, 8000),
description: str(body.description, 2000),
cta: str(body.cta, 120),
media_url: str(body.media_url, 2000),
brand_name: str(body.brand_name, 200),
landing_url: str(body.landing_url, 2000),
};
}
export async function GET() {
const supabase = await createClient();
@@ -10,17 +87,67 @@ export async function GET() {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { tenantId, viaView } = await resolveTenantId(user);
if (viaView && tenantId) {
const headers = serviceHeaders();
if (!headers) {
return NextResponse.json(
{ creatives: [], warning: 'Service role required for superadmin view' },
{ status: 200 }
);
}
const r = await fetch(
`${supabaseUrl()}/rest/v1/creatives?tenant_id=eq.${encodeURIComponent(tenantId)}&select=${SELECT}&order=updated_at.desc`,
{ headers, cache: 'no-store' }
);
if (!r.ok) {
// Fallback if preview columns not migrated yet
const fallback = await fetch(
`${supabaseUrl()}/rest/v1/creatives?tenant_id=eq.${encodeURIComponent(tenantId)}&select=id,title,status,mime_type,bytes,created_at,updated_at&order=updated_at.desc`,
{ headers, cache: 'no-store' }
);
if (fallback.ok) {
return NextResponse.json({
creatives: await fallback.json(),
viewTenantId: tenantId,
warning:
'Aplica sql/creatives_preview.sql para canales, copy y media preview',
});
}
return NextResponse.json({
creatives: [],
warning: await r.text(),
viewTenantId: tenantId,
});
}
return NextResponse.json({
creatives: await r.json(),
viewTenantId: tenantId,
});
}
const { data, error } = await supabase
.from('creatives')
.select('id,title,status,mime_type,bytes,created_at,updated_at')
.select(SELECT)
.order('updated_at', { ascending: false });
if (error) {
// Table may not exist yet — soft message for pre-migration
return NextResponse.json(
{ creatives: [], warning: error.message },
{ status: 200 }
);
const { data: basic, error: e2 } = await supabase
.from('creatives')
.select('id,title,status,mime_type,bytes,created_at,updated_at')
.order('updated_at', { ascending: false });
if (e2) {
return NextResponse.json(
{ creatives: [], warning: error.message },
{ status: 200 }
);
}
return NextResponse.json({
creatives: basic ?? [],
warning:
'Aplica sql/creatives_preview.sql para canales, copy y media preview',
});
}
return NextResponse.json({ creatives: data ?? [] });
@@ -35,32 +162,179 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const tenantId =
user.app_metadata?.tenant_id || user.user_metadata?.tenant_id || null;
const { tenantId, viaView } = await resolveTenantId(user);
if (!tenantId) {
return NextResponse.json({ error: 'Usuario sin tenant_id' }, { status: 403 });
}
const body = await req.json().catch(() => ({}));
const title = typeof body.title === 'string' ? body.title.trim() : '';
const body = (await req.json().catch(() => ({}))) as Record<string, unknown>;
const title = str(body.title, 300);
if (!title) {
return NextResponse.json({ error: 'title requerido' }, { status: 400 });
}
const row = {
tenant_id: tenantId,
title,
status: 'draft',
created_by: user.id,
...previewFields(body),
};
if (viaView) {
const headers = serviceHeaders();
if (!headers) {
return NextResponse.json(
{ error: 'Service role required' },
{ status: 503 }
);
}
const r = await fetch(`${supabaseUrl()}/rest/v1/creatives`, {
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 data = await r.json();
return NextResponse.json(
{ creative: Array.isArray(data) ? data[0] : data },
{ status: 201 }
);
}
const { data, error } = await supabase
.from('creatives')
.insert({
tenant_id: tenantId,
title,
status: 'draft',
created_by: user.id,
})
.select('id,title,status,created_at')
.insert(row)
.select(SELECT)
.single();
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
// Retry without preview columns if migration pending
const { data: basic, error: e2 } = await supabase
.from('creatives')
.insert({
tenant_id: tenantId,
title,
status: 'draft',
created_by: user.id,
})
.select('id,title,status,created_at')
.single();
if (e2) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json(
{
creative: basic,
warning: 'Preview columns missing — apply sql/creatives_preview.sql',
},
{ status: 201 }
);
}
return NextResponse.json({ creative: data }, { status: 201 });
}
export async function PATCH(req: NextRequest) {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = (await req.json().catch(() => ({}))) as Record<string, unknown>;
const id = str(body.id, 80);
if (!id) {
return NextResponse.json({ error: 'id requerido' }, { status: 400 });
}
const clean: Record<string, unknown> = {
updated_at: new Date().toISOString(),
};
if (typeof body.status === 'string' && ALLOWED_STATUS.has(body.status)) {
clean.status = body.status;
}
const title = str(body.title, 300);
if (title) clean.title = title;
for (const [k, v] of Object.entries(previewFields(body))) {
if (k in body) clean[k] = v;
}
const agency = isAgencyRole(userRole(user));
const headers = serviceHeaders();
if (agency && headers) {
const r = await fetch(
`${supabaseUrl()}/rest/v1/creatives?id=eq.${encodeURIComponent(id)}`,
{
method: 'PATCH',
headers: {
...headers,
'Content-Type': 'application/json',
Prefer: 'return=representation',
},
body: JSON.stringify(clean),
}
);
if (!r.ok) {
return NextResponse.json({ error: await r.text() }, { status: 500 });
}
const data = await r.json();
return NextResponse.json({ creative: data[0] || data });
}
const { data, error } = await supabase
.from('creatives')
.update(clean)
.eq('id', id)
.select(SELECT)
.single();
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json({ creative: data });
}
export async function DELETE(req: NextRequest) {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const id = req.nextUrl.searchParams.get('id')?.trim() || '';
if (!id) {
return NextResponse.json({ error: 'id requerido' }, { status: 400 });
}
const agency = isAgencyRole(userRole(user));
const headers = serviceHeaders();
if (agency && headers) {
const r = await fetch(
`${supabaseUrl()}/rest/v1/creatives?id=eq.${encodeURIComponent(id)}`,
{ method: 'DELETE', headers }
);
if (!r.ok) {
return NextResponse.json({ error: await r.text() }, { status: 500 });
}
return NextResponse.json({ ok: true });
}
const { error } = await supabase.from('creatives').delete().eq('id', id);
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json({ ok: true });
}

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 { resolveActiveTenant } from '@/lib/tenant-context';
import {
ASSETS_BUCKET,
MAX_ASSET_BYTES,
ensureAssetsBucket,
sanitizeFileName,
serviceSupabase,
} from '@/lib/assets';
import { randomUUID } from 'crypto';
export const dynamic = 'force-dynamic';
export const runtime = 'nodejs';
const ALLOWED_MIME = new Set([
'image/jpeg',
'image/png',
'image/webp',
'image/gif',
'video/mp4',
'video/webm',
'video/quicktime',
]);
/**
* POST multipart: upload creative media and attach to a creative row.
* fields: file, creativeId? (optional — creates draft if missing), title?
*/
export async function POST(req: NextRequest) {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const ctx = await resolveActiveTenant(user);
if (!ctx.tenantId || ctx.portfolioWide) {
return NextResponse.json(
{ error: 'Selecciona un cliente en Contexto' },
{ status: 400 }
);
}
const form = await req.formData();
const file = form.get('file');
if (!(file instanceof File)) {
return NextResponse.json({ error: 'file requerido' }, { status: 400 });
}
if (file.size <= 0 || file.size > MAX_ASSET_BYTES) {
return NextResponse.json(
{ error: `Archivo vacío o > ${MAX_ASSET_BYTES / (1024 * 1024)} MB` },
{ status: 400 }
);
}
const mime = file.type || 'application/octet-stream';
if (!ALLOWED_MIME.has(mime) && !mime.startsWith('image/') && !mime.startsWith('video/')) {
return NextResponse.json(
{ error: 'Solo imagen o vídeo (jpg/png/webp/gif/mp4/webm)' },
{ status: 400 }
);
}
const bucketOk = await ensureAssetsBucket();
if (!bucketOk.ok) {
return NextResponse.json(
{ error: bucketOk.error || 'No se pudo asegurar el bucket' },
{ status: 500 }
);
}
const svc = serviceSupabase();
if (!svc) {
return NextResponse.json({ error: 'Service role required' }, { status: 503 });
}
let creativeId =
typeof form.get('creativeId') === 'string'
? String(form.get('creativeId')).trim()
: '';
const title =
typeof form.get('title') === 'string'
? String(form.get('title')).trim()
: file.name.replace(/\.[^.]+$/, '') || 'Creativo subido';
const headers = serviceHeaders();
const agency = isAgencyRole(userRole(user));
if (!creativeId) {
const row = {
tenant_id: ctx.tenantId,
title: title.slice(0, 300),
status: 'draft',
channel: 'meta_ads',
format: mime.startsWith('video/') ? 'video' : 'image',
created_by: user.id,
};
if (agency && headers) {
const r = await fetch(`${supabaseUrl()}/rest/v1/creatives`, {
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 data = await r.json();
creativeId = Array.isArray(data) ? data[0]?.id : data?.id;
} else {
const { data, error } = await supabase
.from('creatives')
.insert(row)
.select('id')
.single();
if (error || !data) {
return NextResponse.json(
{ error: error?.message || 'No se pudo crear' },
{ status: 500 }
);
}
creativeId = data.id;
}
}
if (!creativeId) {
return NextResponse.json({ error: 'creativeId inválido' }, { status: 400 });
}
const safeName = sanitizeFileName(file.name || 'creative.bin');
const storagePath = `${ctx.tenantId}/creatives/${creativeId}/${randomUUID()}-${safeName}`;
const buf = Buffer.from(await file.arrayBuffer());
const up = await svc.storage.from(ASSETS_BUCKET).upload(storagePath, buf, {
contentType: mime,
upsert: false,
});
if (up.error) {
return NextResponse.json({ error: up.error.message }, { status: 500 });
}
const mediaUrl = `/api/creatives/media?id=${encodeURIComponent(creativeId)}`;
const patch = {
storage_path: storagePath,
mime_type: mime,
bytes: file.size,
media_url: mediaUrl,
updated_at: new Date().toISOString(),
};
if (agency && headers) {
const r = await fetch(
`${supabaseUrl()}/rest/v1/creatives?id=eq.${encodeURIComponent(creativeId)}`,
{
method: 'PATCH',
headers: {
...headers,
'Content-Type': 'application/json',
Prefer: 'return=representation',
},
body: JSON.stringify(patch),
}
);
if (!r.ok) {
await svc.storage.from(ASSETS_BUCKET).remove([storagePath]);
return NextResponse.json({ error: await r.text() }, { status: 500 });
}
const data = await r.json();
return NextResponse.json({
creative: Array.isArray(data) ? data[0] : data,
media_url: mediaUrl,
});
}
const { data, error } = await supabase
.from('creatives')
.update(patch)
.eq('id', creativeId)
.select('*')
.single();
if (error) {
await svc.storage.from(ASSETS_BUCKET).remove([storagePath]);
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json({ creative: data, media_url: mediaUrl });
}

View File

@@ -0,0 +1,43 @@
import { NextRequest, NextResponse } from 'next/server';
import { assertCronAuth } from '@/lib/cron-auth';
import { syncAdsForTenants } from '@/lib/ads-sync';
export const dynamic = 'force-dynamic';
export const maxDuration = 300;
/**
* Daily Meta + Google Ads → ad_daily.
* Auth: Authorization: Bearer $CRON_SECRET
*/
export async function GET(req: NextRequest) {
const denied = assertCronAuth(req);
if (denied) return denied;
const days = Math.min(
90,
Math.max(3, Number(req.nextUrl.searchParams.get('days')) || 7)
);
try {
const { results, days: d } = await syncAdsForTenants({ days });
const ok = results.filter((r) => r.metaRows + r.googleRows > 0).length;
const withErrors = results.filter((r) => r.errors.length).length;
return NextResponse.json({
ok: true,
days: d,
synced: ok,
withErrors,
results,
at: new Date().toISOString(),
});
} catch (e) {
return NextResponse.json(
{ ok: false, error: e instanceof Error ? e.message : 'sync failed' },
{ status: 500 }
);
}
}
export async function POST(req: NextRequest) {
return GET(req);
}

View File

@@ -0,0 +1,100 @@
import { NextRequest, NextResponse } from 'next/server';
import { assertCronAuth } from '@/lib/cron-auth';
import { syncAllConnectedOrganic } from '@/lib/google-organic';
import { getAgencyOAuthStatus } from '@/lib/google-drive';
import { syncAdsForTenants } from '@/lib/ads-sync';
import {
syncAllSuperhumanTenants,
superhumanConfigured,
} from '@/lib/superhuman-docs';
export const dynamic = 'force-dynamic';
export const maxDuration = 300;
/**
* Master daily job: organic + ads + superhuman.
* Dokploy (daily ~06:00 UTC):
* curl -sS -H "Authorization: Bearer $CRON_SECRET" \
* "https://app.4ventures.es/api/cron/daily-sync"
*/
export async function GET(req: NextRequest) {
const denied = assertCronAuth(req);
if (denied) return denied;
const days = Math.min(
28,
Math.max(3, Number(req.nextUrl.searchParams.get('days')) || 7)
);
const out: Record<string, unknown> = {
ok: true,
days,
at: new Date().toISOString(),
};
// 1) Organic
try {
const oauth = await getAgencyOAuthStatus();
if (!oauth.connected) {
out.organic = { ok: false, warning: 'Google agencia no conectado' };
} else {
out.organic = {
ok: true,
...(await syncAllConnectedOrganic({ days })),
};
}
} catch (e) {
out.organic = {
ok: false,
error: e instanceof Error ? e.message : 'organic failed',
};
}
// 2) Ads
try {
const ads = await syncAdsForTenants({ 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,
};
} catch (e) {
out.ads = {
ok: false,
error: e instanceof Error ? e.message : 'ads failed',
};
}
// 3) Superhuman
try {
if (!superhumanConfigured()) {
out.superhuman = {
ok: false,
warning: 'SUPERHUMAN_DOCS_API_KEY no configurado',
};
} else {
out.superhuman = {
ok: true,
...(await syncAllSuperhumanTenants({
maxEntries: 80,
exportBodies: true,
})),
};
}
} catch (e) {
out.superhuman = {
ok: false,
error: e instanceof Error ? e.message : 'superhuman failed',
};
}
const failed = ['organic', 'ads', 'superhuman'].some(
(k) => (out[k] as { ok?: boolean } | undefined)?.ok === false
);
return NextResponse.json(out, { status: failed ? 207 : 200 });
}
export async function POST(req: NextRequest) {
return GET(req);
}

View File

@@ -0,0 +1,48 @@
import { NextRequest, NextResponse } from 'next/server';
import { assertCronAuth } from '@/lib/cron-auth';
import { syncAllConnectedOrganic } from '@/lib/google-organic';
import { getAgencyOAuthStatus } from '@/lib/google-drive';
export const dynamic = 'force-dynamic';
export const maxDuration = 300;
/**
* Daily organic sync for all connected tenants.
* Auth: Authorization: Bearer $CRON_SECRET OR ?secret=$CRON_SECRET
*/
export async function GET(req: NextRequest) {
const denied = assertCronAuth(req);
if (denied) return denied;
const status = await getAgencyOAuthStatus();
if (!status.connected) {
return NextResponse.json({
ok: false,
warning: 'Google agencia no conectado',
});
}
const days = Math.min(
28,
Math.max(3, Number(req.nextUrl.searchParams.get('days')) || 7)
);
try {
const result = await syncAllConnectedOrganic({ days });
return NextResponse.json({
ok: true,
days,
...result,
at: new Date().toISOString(),
});
} catch (e) {
return NextResponse.json(
{ ok: false, error: e instanceof Error ? e.message : 'sync failed' },
{ status: 500 }
);
}
}
export async function POST(req: NextRequest) {
return GET(req);
}

View File

@@ -0,0 +1,46 @@
import { NextRequest, NextResponse } from 'next/server';
import { assertCronAuth } from '@/lib/cron-auth';
import {
syncAllSuperhumanTenants,
superhumanConfigured,
} from '@/lib/superhuman-docs';
export const dynamic = 'force-dynamic';
export const maxDuration = 300;
/**
* Daily Superhuman / Seguimiento sync for all tenants.
* Auth: Authorization: Bearer $CRON_SECRET
*/
export async function GET(req: NextRequest) {
const denied = assertCronAuth(req);
if (denied) return denied;
if (!superhumanConfigured()) {
return NextResponse.json({
ok: false,
warning: 'SUPERHUMAN_DOCS_API_KEY no configurado',
});
}
try {
const result = await syncAllSuperhumanTenants({
maxEntries: 100,
exportBodies: true,
});
return NextResponse.json({
ok: true,
...result,
at: new Date().toISOString(),
});
} catch (e) {
return NextResponse.json(
{ ok: false, error: e instanceof Error ? e.message : 'sync failed' },
{ status: 500 }
);
}
}
export async function POST(req: NextRequest) {
return GET(req);
}

View File

@@ -0,0 +1,652 @@
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 type { GeoSnapshot, OrganicDailyRow, SeoAuditRow } from '@/lib/organic';
import {
fetchGa4TopLandings,
fetchGscTopRows,
organicNeedsRefresh,
syncAllConnectedOrganic,
} from '@/lib/google-organic';
import { getAgencyOAuthStatus } from '@/lib/google-drive';
import { isoYesterday } from '@/lib/ad-tracker-range';
import { runSeoSiteAudit } from '@/lib/seo-audit';
export const dynamic = 'force-dynamic';
export const maxDuration = 120;
async function loadLiveSeoDims(
integrations: { provider?: string; status?: string; external_account_id?: string | null; meta?: { property_id?: string; site_url?: string } }[]
): Promise<{
gscQueries: { key: string; clicks: number; impressions: number; ctr: number; position: number }[];
gscPages: { key: string; clicks: number; impressions: number; ctr: number; position: number }[];
ga4Landings: { page: string; sessions: number; users: number }[];
liveWarning?: string;
}> {
const empty = { gscQueries: [], gscPages: [], ga4Landings: [] };
try {
const oauth = await getAgencyOAuthStatus();
if (!oauth.connected) return empty;
const gsc = integrations.find(
(i) => i.provider === 'search_console' && i.status === 'connected'
);
const ga4 = integrations.find(
(i) => i.provider === 'ga4' && i.status === 'connected'
);
const until = isoYesterday();
const sinceDate = new Date(`${until}T12:00:00Z`);
sinceDate.setUTCDate(sinceDate.getUTCDate() - 27);
const since = sinceDate.toISOString().slice(0, 10);
const siteUrl =
gsc?.meta?.site_url ||
(gsc?.external_account_id?.includes('http') ||
gsc?.external_account_id?.startsWith('sc-domain:')
? gsc.external_account_id
: null);
const propertyId =
ga4?.meta?.property_id ||
(ga4?.external_account_id?.startsWith('properties/')
? ga4.external_account_id
: ga4?.external_account_id?.match(/^\d+$/)
? `properties/${ga4.external_account_id}`
: null);
const warnings: string[] = [];
let gscQueries: Awaited<ReturnType<typeof fetchGscTopRows>> = [];
let gscPages: Awaited<ReturnType<typeof fetchGscTopRows>> = [];
let ga4Landings: Awaited<ReturnType<typeof fetchGa4TopLandings>> = [];
if (siteUrl) {
try {
gscQueries = await fetchGscTopRows({
siteUrl,
startDate: since,
endDate: until,
dimension: 'query',
});
} catch (e) {
warnings.push(e instanceof Error ? e.message : 'gsc queries');
}
try {
gscPages = await fetchGscTopRows({
siteUrl,
startDate: since,
endDate: until,
dimension: 'page',
});
} catch (e) {
warnings.push(e instanceof Error ? e.message : 'gsc pages');
}
}
if (propertyId) {
try {
ga4Landings = await fetchGa4TopLandings({
propertyId,
startDate: since,
endDate: until,
});
} catch (e) {
warnings.push(e instanceof Error ? e.message : 'ga4 landings');
}
}
return {
gscQueries,
gscPages,
ga4Landings,
liveWarning: warnings[0],
};
} catch {
return empty;
}
}
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);
const agency = isAgencyRole(userRole(user));
return { user, ctx, agency, headers: serviceHeaders() };
}
/** GET — organic series + latest GEO snapshot + integration status for active tenant. */
export async function GET(req: NextRequest) {
const g = await gate();
if ('error' in g && g.error) return g.error;
const { ctx, agency, headers } = g as Awaited<ReturnType<typeof gate>> & {
ctx: { tenantId: string | null; portfolioWide: boolean; label: string };
agency: boolean;
headers: ReturnType<typeof serviceHeaders>;
};
if (ctx.portfolioWide || !ctx.tenantId) {
return NextResponse.json({
portfolioWide: true,
organic: [] as OrganicDailyRow[],
geo: [] as GeoSnapshot[],
seoAudits: [] as SeoAuditRow[],
integrations: [],
warning: 'Selecciona un cliente en Contexto para ver Orgánico.',
});
}
const tid = ctx.tenantId;
const since = req.nextUrl.searchParams.get('since') || '';
const until = req.nextUrl.searchParams.get('until') || '';
const skipAuto = req.nextUrl.searchParams.get('skipAutoSync') === '1';
// Keep Orgánico fresh when opening the page (if Google OAuth is connected)
let autoSynced: { daysWritten: number; warning?: string } | null = null;
if (!skipAuto) {
try {
const oauth = await getAgencyOAuthStatus();
if (oauth.connected && (await organicNeedsRefresh(tid))) {
const r = await syncAllConnectedOrganic({ tenantId: tid, days: 28 });
const one = r.results.find((x) => x.tenantId === tid);
autoSynced = {
daysWritten: one?.daysWritten || 0,
warning: one?.warning,
};
}
} catch {
// non-fatal — still return cached organic rows
}
}
if (agency && headers) {
const base = supabaseUrl();
const dayFilter =
since && until
? `&day=gte.${since}&day=lte.${until}`
: '';
const [oRes, gRes, iRes, tRes, aRes] = await Promise.all([
fetch(
`${base}/rest/v1/organic_daily?tenant_id=eq.${encodeURIComponent(tid)}&select=*&order=day.asc${dayFilter}`,
{ headers, cache: 'no-store' }
),
fetch(
`${base}/rest/v1/geo_snapshots?tenant_id=eq.${encodeURIComponent(tid)}&select=*&order=created_at.desc&limit=12`,
{ headers, cache: 'no-store' }
),
fetch(
`${base}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(tid)}&provider=in.(ga4,search_console,tag_manager)&select=provider,status,enabled,external_account_id,last_sync_at,error_message,meta`,
{ headers, cache: 'no-store' }
),
fetch(
`${base}/rest/v1/tenants?id=eq.${encodeURIComponent(tid)}&select=settings`,
{ headers, cache: 'no-store' }
),
fetch(
`${base}/rest/v1/seo_audits?tenant_id=eq.${encodeURIComponent(tid)}&select=*&order=created_at.desc&limit=20`,
{ headers, cache: 'no-store' }
),
]);
const warningParts: string[] = [];
let organic: OrganicDailyRow[] = [];
let geo: GeoSnapshot[] = [];
let integrations: unknown[] = [];
let workspace: Record<string, unknown> = {};
let seoAudits: SeoAuditRow[] = [];
if (oRes.ok) organic = await oRes.json();
else {
const t = await oRes.text();
warningParts.push(
t.includes('organic_daily')
? 'Aplicar sql/organic_geo.sql'
: t.slice(0, 120)
);
}
if (gRes.ok) geo = await gRes.json();
else {
const t = await gRes.text();
if (t.includes('geo_snapshots')) {
warningParts.push('Aplicar sql/organic_geo.sql (GEO)');
}
}
if (iRes.ok) integrations = await iRes.json();
if (tRes.ok) {
const rows = await tRes.json();
workspace = rows[0]?.settings?.organico || {};
}
if (aRes.ok) {
seoAudits = await aRes.json();
} else {
const t = await aRes.text();
if (t.includes('seo_audits')) {
const fallback = Array.isArray(workspace.seoAudits)
? (workspace.seoAudits as SeoAuditRow[])
: [];
seoAudits = fallback;
}
}
const live = await loadLiveSeoDims(
integrations as {
provider?: string;
status?: string;
external_account_id?: string | null;
meta?: { property_id?: string; site_url?: string };
}[]
);
return NextResponse.json({
portfolioWide: false,
label: ctx.label,
tenantId: tid,
organic,
geo,
seoAudits,
integrations,
workspace,
autoSynced,
gscQueries: live.gscQueries,
gscPages: live.gscPages,
ga4Landings: live.ga4Landings,
warning:
warningParts[0] ||
autoSynced?.warning ||
live.liveWarning ||
null,
});
}
const supabase = await createClient();
let organicQ = supabase
.from('organic_daily')
.select('*')
.order('day', { ascending: true });
if (since) organicQ = organicQ.gte('day', since);
if (until) organicQ = organicQ.lte('day', until);
const [o, ge, integ, ten, aud] = await Promise.all([
organicQ,
supabase
.from('geo_snapshots')
.select('*')
.order('created_at', { ascending: false })
.limit(12),
supabase
.from('integrations')
.select(
'provider,status,enabled,external_account_id,last_sync_at,error_message,meta'
)
.in('provider', ['ga4', 'search_console', 'tag_manager']),
supabase.from('tenants').select('settings').eq('id', tid).maybeSingle(),
supabase
.from('seo_audits')
.select('*')
.order('created_at', { ascending: false })
.limit(20),
]);
const workspace =
(ten.data?.settings as { organico?: Record<string, unknown> } | null)
?.organico || {};
let seoAudits: SeoAuditRow[] = (aud.data as SeoAuditRow[]) || [];
if (aud.error && Array.isArray(workspace.seoAudits)) {
seoAudits = workspace.seoAudits as SeoAuditRow[];
}
const live = await loadLiveSeoDims(
(integ.data || []) as {
provider?: string;
status?: string;
external_account_id?: string | null;
meta?: { property_id?: string; site_url?: string };
}[]
);
return NextResponse.json({
portfolioWide: false,
label: ctx.label,
tenantId: tid,
organic: o.data || [],
geo: ge.data || [],
seoAudits,
integrations: integ.data || [],
workspace,
autoSynced,
gscQueries: live.gscQueries,
gscPages: live.gscPages,
ga4Landings: live.ga4Landings,
warning:
o.error?.message ||
ge.error?.message ||
autoSynced?.warning ||
live.liveWarning ||
null,
});
}
/** POST — agency: upsert organic day OR create GEO snapshot. */
export async function POST(req: NextRequest) {
const g = await gate();
if ('error' in g && g.error) return g.error;
const { ctx, agency, headers } = g as Awaited<ReturnType<typeof gate>> & {
ctx: { tenantId: string | null };
agency: boolean;
headers: ReturnType<typeof serviceHeaders>;
};
if (!agency) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
if (!headers) {
return NextResponse.json({ error: 'no service role' }, { status: 503 });
}
if (!ctx.tenantId) {
return NextResponse.json(
{ error: 'Selecciona un cliente en Contexto' },
{ status: 400 }
);
}
const body = await req.json().catch(() => ({}));
const kind =
body.kind === 'geo'
? 'geo'
: body.kind === 'workspace'
? 'workspace'
: body.kind === 'seo-audit'
? 'seo-audit'
: 'organic';
const base = supabaseUrl();
if (kind === 'seo-audit') {
const iRes = await fetch(
`${base}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(ctx.tenantId)}&provider=in.(ga4,search_console)&select=provider,status,external_account_id,meta`,
{ headers, cache: 'no-store' }
);
const integrations = iRes.ok
? ((await iRes.json()) as {
provider?: string;
status?: string;
external_account_id?: string | null;
meta?: { site_url?: string; property_id?: string };
}[])
: [];
const gsc = integrations.find(
(i) => i.provider === 'search_console' && i.status === 'connected'
);
const ga4 = integrations.find(
(i) => i.provider === 'ga4' && i.status === 'connected'
);
const gscSiteUrl =
gsc?.meta?.site_url ||
(gsc?.external_account_id?.includes('http') ||
gsc?.external_account_id?.startsWith('sc-domain:')
? gsc.external_account_id
: null);
let samplePages: string[] = [];
if (gscSiteUrl) {
try {
const until = isoYesterday();
const sinceDate = new Date(`${until}T12:00:00Z`);
sinceDate.setUTCDate(sinceDate.getUTCDate() - 27);
const since = sinceDate.toISOString().slice(0, 10);
const pages = await fetchGscTopRows({
siteUrl: gscSiteUrl,
startDate: since,
endDate: until,
dimension: 'page',
});
samplePages = pages.map((p) => p.key).slice(0, 3);
} catch {
// non-fatal
}
}
const result = await runSeoSiteAudit({
gscSiteUrl,
ga4Connected: ga4?.status === 'connected',
gscConnected: gsc?.status === 'connected',
samplePages,
});
const row = {
tenant_id: ctx.tenantId,
site_url: result.siteUrl,
score: result.score,
grade: result.grade,
warning_count: result.warningCount,
critical_count: result.criticalCount,
checks: result.checks,
summary: result.summary,
};
const r = await fetch(`${base}/rest/v1/seo_audits`, {
method: 'POST',
headers: {
...headers,
'Content-Type': 'application/json',
Prefer: 'return=representation',
},
body: JSON.stringify(row),
});
if (r.ok) {
const saved = (await r.json())[0] as SeoAuditRow;
return NextResponse.json({ audit: saved });
}
// Fallback: persist last 20 audits in tenants.settings.organico.seoAudits
const msg = await r.text();
if (!msg.includes('seo_audits')) {
return NextResponse.json({ error: msg.slice(0, 300) }, { status: 500 });
}
const cur = await fetch(
`${base}/rest/v1/tenants?id=eq.${encodeURIComponent(ctx.tenantId)}&select=settings`,
{ headers, cache: 'no-store' }
);
let settings: Record<string, unknown> = {};
if (cur.ok) {
const rows = await cur.json();
settings =
rows[0]?.settings && typeof rows[0].settings === 'object'
? { ...rows[0].settings }
: {};
}
const prevOrg =
settings.organico && typeof settings.organico === 'object'
? (settings.organico as Record<string, unknown>)
: {};
const prevAudits = Array.isArray(prevOrg.seoAudits)
? (prevOrg.seoAudits as SeoAuditRow[])
: [];
const audit: SeoAuditRow = {
id: `ws-${Date.now()}`,
...row,
created_at: result.createdAt,
};
settings.organico = {
...prevOrg,
seoAudits: [audit, ...prevAudits].slice(0, 20),
};
const patch = await fetch(
`${base}/rest/v1/tenants?id=eq.${encodeURIComponent(ctx.tenantId)}`,
{
method: 'PATCH',
headers: {
...headers,
'Content-Type': 'application/json',
Prefer: 'return=representation',
},
body: JSON.stringify({ settings }),
}
);
if (!patch.ok) {
return NextResponse.json(
{ error: (await patch.text()).slice(0, 300) },
{ status: 500 }
);
}
return NextResponse.json({
audit,
warning: 'seo_audits table missing — histórico en settings (aplicar sql/seo_audits.sql)',
});
}
if (kind === 'workspace') {
// Merge organico workspace into tenants.settings
const cur = await fetch(
`${base}/rest/v1/tenants?id=eq.${encodeURIComponent(ctx.tenantId)}&select=settings`,
{ headers, cache: 'no-store' }
);
let settings: Record<string, unknown> = {};
if (cur.ok) {
const rows = await cur.json();
settings =
rows[0]?.settings && typeof rows[0].settings === 'object'
? { ...rows[0].settings }
: {};
}
const prev =
settings.organico && typeof settings.organico === 'object'
? (settings.organico as Record<string, unknown>)
: {};
settings.organico = {
...prev,
...(body.workspace && typeof body.workspace === 'object'
? body.workspace
: {}),
};
const r = await fetch(
`${base}/rest/v1/tenants?id=eq.${encodeURIComponent(ctx.tenantId)}`,
{
method: 'PATCH',
headers: {
...headers,
'Content-Type': 'application/json',
Prefer: 'return=representation',
},
body: JSON.stringify({ settings }),
}
);
if (!r.ok) {
return NextResponse.json(
{ error: (await r.text()).slice(0, 300) },
{ status: 500 }
);
}
return NextResponse.json({
workspace: (await r.json())[0]?.settings?.organico || settings.organico,
});
}
if (kind === 'organic') {
const day = typeof body.day === 'string' ? body.day.slice(0, 10) : '';
if (!/^\d{4}-\d{2}-\d{2}$/.test(day)) {
return NextResponse.json({ error: 'day YYYY-MM-DD requerido' }, { status: 400 });
}
const row = {
tenant_id: ctx.tenantId,
day,
gsc_clicks: Number(body.gsc_clicks) || 0,
gsc_impressions: Number(body.gsc_impressions) || 0,
gsc_ctr:
body.gsc_ctr != null
? Number(body.gsc_ctr)
: Number(body.gsc_impressions) > 0
? (100 * Number(body.gsc_clicks || 0)) /
Number(body.gsc_impressions)
: null,
gsc_avg_position:
body.gsc_avg_position != null ? Number(body.gsc_avg_position) : null,
ga4_sessions: Number(body.ga4_sessions) || 0,
ga4_users: Number(body.ga4_users) || 0,
ga4_organic_sessions: Number(body.ga4_organic_sessions) || 0,
ga4_conversions: Number(body.ga4_conversions) || 0,
source: 'manual',
};
const r = await fetch(
`${base}/rest/v1/organic_daily?on_conflict=tenant_id,day`,
{
method: 'POST',
headers: {
...headers,
'Content-Type': 'application/json',
Prefer: 'resolution=merge-duplicates,return=representation',
},
body: JSON.stringify(row),
}
);
if (!r.ok) {
const msg = await r.text();
return NextResponse.json(
{
error: msg.includes('organic_daily')
? 'Aplicar sql/organic_geo.sql'
: msg.slice(0, 300),
},
{ status: 500 }
);
}
return NextResponse.json({ row: (await r.json())[0] });
}
// GEO snapshot
const snapshot = {
tenant_id: ctx.tenantId,
label:
typeof body.label === 'string' && body.label.trim()
? body.label.trim()
: 'Medición GEO',
period_start: body.period_start || null,
period_end: body.period_end || null,
share_of_model:
body.share_of_model != null ? Number(body.share_of_model) : null,
engines: body.engines && typeof body.engines === 'object' ? body.engines : {},
official_citation_pct:
body.official_citation_pct != null
? Number(body.official_citation_pct)
: null,
sentiment_score:
body.sentiment_score != null ? Number(body.sentiment_score) : null,
competitors: Array.isArray(body.competitors) ? body.competitors : [],
categories: Array.isArray(body.categories) ? body.categories : [],
top_sources: Array.isArray(body.top_sources) ? body.top_sources : [],
prompts_analyzed:
body.prompts_analyzed != null ? Number(body.prompts_analyzed) : null,
responses_analyzed:
body.responses_analyzed != null ? Number(body.responses_analyzed) : null,
study_url: typeof body.study_url === 'string' ? body.study_url : null,
notes: typeof body.notes === 'string' ? body.notes : null,
};
const r = await fetch(`${base}/rest/v1/geo_snapshots`, {
method: 'POST',
headers: {
...headers,
'Content-Type': 'application/json',
Prefer: 'return=representation',
},
body: JSON.stringify(snapshot),
});
if (!r.ok) {
const msg = await r.text();
return NextResponse.json(
{
error: msg.includes('geo_snapshots')
? 'Aplicar sql/organic_geo.sql'
: msg.slice(0, 300),
},
{ status: 500 }
);
}
return NextResponse.json({ snapshot: (await r.json())[0] });
}

View File

@@ -0,0 +1,176 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { resolveActiveTenant, restGet } from '@/lib/tenant-context';
import {
isAgencyRole,
serviceHeaders,
supabaseUrl,
userRole,
} from '@/lib/agency';
import { randomUUID } from 'crypto';
export const dynamic = 'force-dynamic';
export type SeguimientoEntry = {
id: string;
tenant_id: string;
entry_type: string;
title: string;
summary?: string | null;
body_md?: string | null;
browser_link?: string | null;
metrics?: Record<string, unknown>;
occurred_at?: string | null;
synced_at?: string;
source?: string;
};
/** GET — notas + histórico Superhuman (sin series ads). */
export async function GET() {
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);
const agency = isAgencyRole(userRole(user));
if (agency && ctx.portfolioWide) {
return NextResponse.json({
label: ctx.label,
portfolioWide: true,
entries: [],
hint: 'Elige un cliente en el selector para ver su seguimiento.',
});
}
const tenantId = ctx.tenantId;
if (!tenantId) {
return NextResponse.json({ error: 'Sin tenant' }, { status: 400 });
}
let entries: SeguimientoEntry[] = [];
if (agency && ctx.viaView) {
const tid = encodeURIComponent(tenantId);
const e = await restGet<SeguimientoEntry[]>(
`/rest/v1/seguimiento_entries?tenant_id=eq.${tid}&select=*&order=occurred_at.desc.nullslast&limit=300`
);
entries = e.data || [];
} else {
const { data: e } = await supabase
.from('seguimiento_entries')
.select('*')
.order('occurred_at', { ascending: false, nullsFirst: false })
.limit(300);
entries = (e as SeguimientoEntry[]) || [];
}
return NextResponse.json({
label: ctx.label,
portfolioWide: false,
tenantId,
entries,
isAgency: agency,
});
}
/**
* POST — add a manual note / comentario to seguimiento.
* { title?, body, occurredAt? | occurred_at? }
*/
export async function POST(req: NextRequest) {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const ctx = await resolveActiveTenant(user);
if (!ctx.tenantId || ctx.portfolioWide) {
return NextResponse.json(
{ error: 'Selecciona un cliente en Contexto' },
{ status: 400 }
);
}
const body = (await req.json().catch(() => ({}))) as Record<string, unknown>;
const text =
typeof body.body === 'string'
? body.body.trim()
: typeof body.note === 'string'
? body.note.trim()
: '';
const plain = text
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<\/(p|div|li|h[1-6])>/gi, '\n')
.replace(/<[^>]+>/g, '')
.replace(/&nbsp;/gi, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/\s+/g, ' ')
.trim();
if (!plain) {
return NextResponse.json({ error: 'Texto de la nota requerido' }, { status: 400 });
}
const title =
typeof body.title === 'string' && body.title.trim()
? body.title.trim().slice(0, 200)
: `Nota · ${new Date().toLocaleDateString('es-ES')}`;
const occurredRaw =
(typeof body.occurredAt === 'string' && body.occurredAt) ||
(typeof body.occurred_at === 'string' && body.occurred_at) ||
new Date().toISOString();
const row = {
tenant_id: ctx.tenantId,
source: 'manual',
entry_type: 'note',
external_id: `manual-${randomUUID()}`,
title,
summary: plain.slice(0, 280),
body_md: text,
occurred_at: occurredRaw,
synced_at: new Date().toISOString(),
metrics: {},
};
const agency = isAgencyRole(userRole(user));
const headers = serviceHeaders();
if (agency && headers) {
const r = await fetch(`${supabaseUrl()}/rest/v1/seguimiento_entries`, {
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 data = await r.json();
return NextResponse.json(
{ entry: Array.isArray(data) ? data[0] : data },
{ status: 201 }
);
}
const { data, error } = await supabase
.from('seguimiento_entries')
.insert(row)
.select('*')
.single();
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json({ entry: data }, { status: 201 });
}

View File

@@ -1,10 +1,52 @@
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, KpiWeeklyRow } from '@/lib/metrics';
import {
SimChannel,
simulateFromTenantMetrics,
} from '@/lib/simulate';
export const dynamic = 'force-dynamic';
async function loadTenantMetrics(tenantId: string, agencyView: boolean) {
let daily: AdDailyRow[] = [];
let weekly: KpiWeeklyRow[] = [];
if (agencyView) {
const tid = encodeURIComponent(tenantId);
const [d, w] = await Promise.all([
restGet<AdDailyRow[]>(
`/rest/v1/ad_daily?tenant_id=eq.${tid}&select=*&order=date.desc&limit=400`
),
restGet<KpiWeeklyRow[]>(
`/rest/v1/kpi_weekly?tenant_id=eq.${tid}&select=*&order=week.desc&limit=52`
),
]);
daily = d.data || [];
weekly = w.data || [];
} else {
const supabase = await createClient();
const [{ data: d }, { data: w }] = await Promise.all([
supabase
.from('ad_daily')
.select('*')
.order('date', { ascending: false })
.limit(400),
supabase
.from('kpi_weekly')
.select('*')
.order('week', { ascending: false })
.limit(52),
]);
daily = (d as AdDailyRow[]) || [];
weekly = (w as KpiWeeklyRow[]) || [];
}
return { daily, weekly };
}
export async function POST(req: NextRequest) {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || 'https://supabase.4ventures.es';
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY?.trim() || '';
const supabase = await createClient();
const {
data: { user },
@@ -13,42 +55,50 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const tenantId =
user.app_metadata?.tenant_id || user.user_metadata?.tenant_id || null;
if (!tenantId) {
const ctx = await resolveActiveTenant(user);
if (!ctx.tenantId || ctx.portfolioWide) {
return NextResponse.json(
{ error: 'Usuario sin tenant_id' },
{ status: 403 }
{ error: 'Selecciona un cliente en Contexto para simular.' },
{ status: 400 }
);
}
const keyForRpc = serviceKey || process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || '';
try {
const body = await req.json();
const spend = parseFloat(body.spend || 1000);
const channel = body.channel || 'meta';
let channel = String(body.channel || 'blended').toLowerCase();
const res = await fetch(`${supabaseUrl}/rest/v1/rpc/simulate_budget`, {
method: 'POST',
headers: {
apikey: keyForRpc,
Authorization: `Bearer ${keyForRpc}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
p_tenant_id: tenantId,
p_additional_spend: spend,
p_target_channel: channel === 'blended' ? null : channel,
}),
if (channel === 'tiktok') {
return NextResponse.json(
{
error:
'TikTok no está activo aún en este portafolio. Usa Meta, Google o blended.',
},
{ status: 400 }
);
}
if (!['meta', 'google', 'blended'].includes(channel)) {
channel = 'blended';
}
const agency = isAgencyRole(userRole(user));
const { daily, weekly } = await loadTenantMetrics(
ctx.tenantId,
agency && ctx.viaView
);
const sim = simulateFromTenantMetrics({
spend: Number.isFinite(spend) && spend > 0 ? spend : 1000,
channel: channel as SimChannel,
daily,
weekly,
});
if (res.ok) {
const data = await res.json();
return NextResponse.json(data);
}
const err = await res.text();
return NextResponse.json({ error: err }, { status: 500 });
return NextResponse.json({
...sim,
tenant_label: ctx.label,
hint: 'Estimación con CPL, cierre y ticket del cliente en contexto (ad_daily + kpi_weekly).',
});
} catch (e: unknown) {
const message = e instanceof Error ? e.message : 'Error';
return NextResponse.json({ error: message }, { status: 500 });

930
src/app/api/social/route.ts Normal file
View File

@@ -0,0 +1,930 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { resolveActiveTenant, restGet } from '@/lib/tenant-context';
import { isAgencyRole, serviceHeaders, supabaseUrl, userRole } from '@/lib/agency';
import { resolveMetaAccessTokens } from '@/lib/ads-discovery';
export const dynamic = 'force-dynamic';
export const maxDuration = 90;
const META = 'https://graph.facebook.com/v21.0';
type Integ = {
provider: string;
status: string;
external_account_id?: string | null;
label?: string | null;
last_sync_at?: string | null;
meta?: Record<string, unknown> | null;
};
async function cacheSocialSnapshot(
tenantId: string,
provider: 'facebook' | 'instagram',
payload: unknown
) {
const headers = serviceHeaders();
if (!headers) return;
const now = new Date().toISOString();
// Merge into meta without wiping other keys: read-modify-write
const r = await fetch(
`${supabaseUrl()}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(tenantId)}&provider=eq.${provider}&select=meta&limit=1`,
{ headers, cache: 'no-store' }
);
let meta: Record<string, unknown> = {};
if (r.ok) {
const rows = (await r.json()) as { meta?: Record<string, unknown> }[];
meta = { ...(rows[0]?.meta || {}) };
}
// Keep a compact snapshot (metrics + up to 12 posts)
const snap =
payload && typeof payload === 'object'
? {
...(payload as object),
posts: Array.isArray((payload as { posts?: unknown }).posts)
? (payload as { posts: unknown[] }).posts.slice(0, 12)
: undefined,
}
: payload;
meta.social_cache = snap;
meta.social_cached_at = now;
await fetch(
`${supabaseUrl()}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(tenantId)}&provider=eq.${provider}`,
{
method: 'PATCH',
headers: {
...headers,
'Content-Type': 'application/json',
Prefer: 'return=minimal',
},
body: JSON.stringify({
meta,
last_sync_at: now,
error_message: null,
}),
}
);
}
function readSocialCache(integrations: Integ[]): {
facebook?: unknown;
instagram?: unknown;
at?: string;
} {
const out: { facebook?: unknown; instagram?: unknown; at?: string } = {};
for (const i of integrations) {
const cache = i.meta?.social_cache;
const at = typeof i.meta?.social_cached_at === 'string'
? i.meta.social_cached_at
: i.last_sync_at || undefined;
if (!cache) continue;
if (i.provider === 'facebook') out.facebook = cache;
if (i.provider === 'instagram') out.instagram = cache;
if (at && (!out.at || at > out.at)) out.at = at;
}
return out;
}
type SocialPost = {
id: string;
platform: 'facebook' | 'instagram';
message?: string;
mediaUrl?: string | null;
thumbnailUrl?: string | null;
permalink?: string | null;
createdAt?: string | null;
likes: number;
comments: number;
shares: number;
reach?: number | null;
impressions?: number | null;
mediaType?: string | null;
/** FEED | REELS | STORY | … */
mediaProductType?: string | null;
/** Slides for CAROUSEL_ALBUM / album */
children?: {
id?: string;
mediaType?: string | null;
mediaUrl?: string | null;
thumbnailUrl?: string | null;
}[];
};
export type DayPoint = { day: string; value: number };
function isVideoType(t?: string | null): boolean {
const s = (t || '').toUpperCase();
return (
s === 'VIDEO' ||
s === 'REELS' ||
s === 'REEL' ||
s.includes('VIDEO') ||
s === 'ANIMATED_IMAGE_VIDEO'
);
}
function formatGraphError(raw: string): string {
try {
const j = JSON.parse(raw) as {
error?: {
message?: string;
code?: number;
error_subcode?: number;
error_user_title?: string;
error_user_msg?: string;
};
};
const e = j.error;
if (!e) return raw.slice(0, 220);
if (e.code === 190 || e.error_subcode === 2069032) {
return (
'Token Meta inválido para esta Página. Usa el system user de la app 4V2030 ' +
'con pages_* + instagram_* + ads_*, asígnalo a la Página y pégalo en Agencia ' +
'(o META_ACCESS_TOKEN en Dokploy).'
);
}
return (
e.error_user_title ||
e.error_user_msg ||
e.message ||
raw
).slice(0, 280);
} catch {
return raw.slice(0, 220);
}
}
async function resolvePageAccessToken(
userToken: string,
pageId: string
): Promise<string> {
const r = await fetch(
`${META}/${encodeURIComponent(pageId)}?fields=access_token,name&access_token=${encodeURIComponent(userToken)}`,
{ cache: 'no-store' }
);
if (r.ok) {
const j = (await r.json()) as { access_token?: string };
if (j.access_token) return j.access_token;
}
const accounts = await fetch(
`${META}/me/accounts?fields=id,access_token&limit=100&access_token=${encodeURIComponent(userToken)}`,
{ cache: 'no-store' }
);
if (accounts.ok) {
const j = (await accounts.json()) as {
data?: { id?: string; access_token?: string }[];
};
const hit = (j.data || []).find((p) => p.id === pageId && p.access_token);
if (hit?.access_token) return hit.access_token;
}
return userToken;
}
function insightSeries(
data: { name?: string; values?: { value?: number; end_time?: string }[] }[] | undefined,
name: string
): DayPoint[] {
const row = data?.find((d) => d.name === name);
if (!row?.values?.length) return [];
return row.values
.map((v) => ({
day: (v.end_time || '').slice(0, 10),
value: typeof v.value === 'number' && Number.isFinite(v.value) ? v.value : 0,
}))
.filter((p) => /^\d{4}-\d{2}-\d{2}$/.test(p.day))
.sort((a, b) => a.day.localeCompare(b.day));
}
/** Meta IG: many metrics only return total_value (not daily values[]). */
function totalValueOf(
data: { name?: string; total_value?: { value?: number } }[] | undefined,
name: string
): number | null {
const row = data?.find((d) => d.name === name);
const v = row?.total_value?.value;
return typeof v === 'number' && Number.isFinite(v) ? v : null;
}
function eachDay(since: string, until: string): string[] {
const out: string[] = [];
const cur = new Date(`${since}T12:00:00Z`);
const end = new Date(`${until}T12:00:00Z`);
while (cur <= end) {
out.push(cur.toISOString().slice(0, 10));
cur.setUTCDate(cur.getUTCDate() + 1);
}
return out;
}
function lastValue(series: DayPoint[]): number | null {
if (!series.length) return null;
return series[series.length - 1].value;
}
function sumSeries(series: DayPoint[]): number {
return series.reduce((a, p) => a + (p.value || 0), 0);
}
function toUnix(isoDay: string, endOfDay = false): number {
const t = Date.parse(`${isoDay}T${endOfDay ? '23:59:59' : '00:00:00'}Z`);
return Math.floor(t / 1000);
}
async function graphGet(
path: string,
token: string,
query: Record<string, string> = {}
): Promise<{ ok: boolean; json: Record<string, unknown>; raw: string }> {
const q = new URLSearchParams({ ...query, access_token: token });
const r = await fetch(`${META}/${path}?${q}`, { cache: 'no-store' });
const raw = await r.text();
let json: Record<string, unknown> = {};
try {
json = JSON.parse(raw) as Record<string, unknown>;
} catch {
/* keep empty */
}
return { ok: r.ok, json, raw };
}
/** Pick first agency token that can read the page (or just /me). */
async function pickWorkingToken(
tokens: string[],
pageId?: string | null
): Promise<string | null> {
for (const t of tokens) {
if (pageId) {
const probe = await graphGet(encodeURIComponent(pageId), t, {
fields: 'id,name',
});
if (probe.ok) return t;
continue;
}
const me = await graphGet('me', t, { fields: 'id,name' });
if (me.ok) return t;
}
return tokens[0] || null;
}
async function fetchPageBundle(
agencyToken: string,
pageId: string,
since: string,
until: string
) {
const token = await resolvePageAccessToken(agencyToken, pageId);
const profileRes = await graphGet(encodeURIComponent(pageId), token, {
fields: 'name,fan_count,followers_count,link,picture{url},engagement,about,website',
});
if (!profileRes.ok) return { error: formatGraphError(profileRes.raw) };
const profile = profileRes.json as Record<string, unknown>;
const sinceU = String(toUnix(since));
const untilU = String(toUnix(until, true));
// Meta deprecated page_impressions / page_fans (Nov 2025).
// Use page_media_view + page_follows (+ unique views).
const metricSets = [
'page_media_view,page_total_media_view_unique,page_post_engagements,page_follows,page_views_total,page_daily_follows,page_actions_post_reactions_total',
'page_media_view,page_total_media_view_unique,page_post_engagements,page_follows,page_views_total',
'page_media_view,page_follows,page_views_total',
'page_follows,page_views_total',
];
let insightData: {
name?: string;
values?: { value?: number; end_time?: string }[];
}[] = [];
let insightsError: string | undefined;
for (const metrics of metricSets) {
const ins = await graphGet(`${encodeURIComponent(pageId)}/insights`, token, {
metric: metrics,
period: 'day',
since: sinceU,
until: untilU,
});
if (ins.ok && Array.isArray(ins.json.data)) {
insightData = ins.json.data as typeof insightData;
break;
}
insightsError = formatGraphError(ins.raw);
}
// If bulk failed, try metrics one-by-one so one deprecated name doesn't wipe the rest
if (!insightData.length) {
const singles = [
'page_media_view',
'page_total_media_view_unique',
'page_post_engagements',
'page_follows',
'page_views_total',
'page_daily_follows',
'page_actions_post_reactions_total',
];
const merged: typeof insightData = [];
for (const metric of singles) {
const ins = await graphGet(`${encodeURIComponent(pageId)}/insights`, token, {
metric,
period: 'day',
since: sinceU,
until: untilU,
});
if (ins.ok && Array.isArray(ins.json.data)) {
merged.push(...(ins.json.data as typeof insightData));
}
}
if (merged.length) {
insightData = merged;
insightsError = undefined;
}
}
const series = {
impressions: insightSeries(insightData, 'page_media_view').length
? insightSeries(insightData, 'page_media_view')
: insightSeries(insightData, 'page_impressions'),
reach: insightSeries(insightData, 'page_total_media_view_unique').length
? insightSeries(insightData, 'page_total_media_view_unique')
: insightSeries(insightData, 'page_impressions_unique'),
postEngagements: insightSeries(insightData, 'page_post_engagements'),
fans: insightSeries(insightData, 'page_follows').length
? insightSeries(insightData, 'page_follows')
: insightSeries(insightData, 'page_fans'),
pageViews: insightSeries(insightData, 'page_views_total'),
follows: insightSeries(insightData, 'page_daily_follows').length
? insightSeries(insightData, 'page_daily_follows')
: insightSeries(insightData, 'page_follows'),
reactions: insightSeries(insightData, 'page_actions_post_reactions_total'),
};
const pageInsights = {
impressions: sumSeries(series.impressions) || lastValue(series.impressions),
reach: sumSeries(series.reach) || lastValue(series.reach),
postEngagements:
sumSeries(series.postEngagements) || lastValue(series.postEngagements),
fans:
lastValue(series.fans) ??
(typeof profile.followers_count === 'number'
? profile.followers_count
: typeof profile.fan_count === 'number'
? profile.fan_count
: null),
pageViews: sumSeries(series.pageViews) || lastValue(series.pageViews),
follows: sumSeries(series.follows) || lastValue(series.follows),
reactions: sumSeries(series.reactions) || lastValue(series.reactions),
};
const posts: SocialPost[] = [];
const seen = new Set<string>();
const postsRes = await graphGet(
`${encodeURIComponent(pageId)}/published_posts`,
token,
{
// No pedir likes.summary / comments.summary: Meta responde (#10)
// pages_read_engagement / Page Public Content Access aunque el permiso
// figure como granted en el user token.
fields:
'id,message,created_time,full_picture,permalink_url,status_type,attachments{media_type,type,url,media,subattachments{media_type,type,url,media}}',
limit: '25',
}
);
let postsError: string | undefined;
if (postsRes.ok) {
const data = (postsRes.json.data as Record<string, unknown>[]) || [];
for (const p of data) {
pushFbPost(posts, seen, p);
}
} else {
const posts2 = await graphGet(`${encodeURIComponent(pageId)}/posts`, token, {
fields:
'id,message,created_time,full_picture,permalink_url,status_type,attachments{media_type,type,url,media,subattachments{media_type,type,url,media}}',
limit: '25',
});
if (posts2.ok) {
const data = (posts2.json.data as Record<string, unknown>[]) || [];
for (const p of data) pushFbPost(posts, seen, p);
} else {
postsError = formatGraphError(postsRes.raw);
}
}
const videosRes = await graphGet(`${encodeURIComponent(pageId)}/videos`, token, {
fields: 'id,description,created_time,source,permalink_url,picture,length,title',
limit: '10',
});
if (videosRes.ok) {
for (const v of (videosRes.json.data as Record<string, unknown>[]) || []) {
if (!v?.id || seen.has(String(v.id))) continue;
posts.push({
id: String(v.id),
platform: 'facebook',
message: String(v.description || v.title || ''),
mediaUrl: (v.source as string) || (v.picture as string) || null,
thumbnailUrl: (v.picture as string) || null,
permalink: (v.permalink_url as string) || null,
createdAt: (v.created_time as string) || null,
likes: 0,
comments: 0,
shares: 0,
mediaType: 'VIDEO',
});
seen.add(String(v.id));
}
}
return {
...profile,
pageInsights,
series,
posts,
postsError,
insightsError: insightData.length ? undefined : insightsError,
since,
until,
};
}
function pushFbPost(
posts: SocialPost[],
seen: Set<string>,
p: Record<string, unknown>
) {
const id = String(p.id || '');
if (!id || seen.has(id)) return;
const att = (p.attachments as { data?: Record<string, unknown>[] })?.data?.[0];
const attType = String(att?.media_type || att?.type || '').toLowerCase();
const media = att?.media as { source?: string; image?: { src?: string } } | undefined;
const subs =
(
att?.subattachments as {
data?: {
media_type?: string;
type?: string;
media?: { source?: string; image?: { src?: string } };
url?: string;
}[];
}
)?.data || [];
const videoSrc =
media?.source ||
(att?.url as string) ||
subs[0]?.media?.source ||
null;
const isVideo =
attType.includes('video') ||
String(p.status_type || '').includes('video') ||
Boolean(videoSrc && /\.mp4|video/i.test(String(videoSrc)));
const isAlbum = attType.includes('album') || subs.length > 1;
const thumb =
(p.full_picture as string) ||
media?.image?.src ||
subs[0]?.media?.image?.src ||
null;
const children = isAlbum
? subs.map((s, i) => {
const v = s.media?.source;
const img = s.media?.image?.src || s.url || null;
const t = String(s.media_type || s.type || '').toLowerCase();
return {
id: `${id}-slide-${i}`,
mediaType: t.includes('video') ? 'VIDEO' : 'IMAGE',
mediaUrl: v || img,
thumbnailUrl: img,
};
})
: undefined;
const likes = p.likes as { summary?: { total_count?: number } } | undefined;
const comments = p.comments as { summary?: { total_count?: number } } | undefined;
const shares = p.shares as { count?: number } | undefined;
posts.push({
id,
platform: 'facebook',
message: String(p.message || ''),
mediaUrl: isVideo ? videoSrc || thumb : thumb || children?.[0]?.mediaUrl || null,
thumbnailUrl: thumb,
permalink: (p.permalink_url as string) || null,
createdAt: (p.created_time as string) || null,
likes: Number(likes?.summary?.total_count || 0),
comments: Number(comments?.summary?.total_count || 0),
shares: Number(shares?.count || 0),
mediaType: isVideo ? 'VIDEO' : isAlbum ? 'CAROUSEL_ALBUM' : thumb ? 'IMAGE' : 'status',
children,
});
seen.add(id);
}
async function fetchIgBundle(
agencyToken: string,
igId: string,
since: string,
until: string
) {
const profileRes = await graphGet(encodeURIComponent(igId), agencyToken, {
fields:
'username,name,followers_count,follows_count,media_count,profile_picture_url,biography,website',
});
if (!profileRes.ok) return { error: formatGraphError(profileRes.raw) };
const profile = profileRes.json as Record<string, unknown>;
const sinceU = String(toUnix(since));
const untilU = String(toUnix(until, true));
// Time-series metrics (daily values[])
let seriesData: {
name?: string;
values?: { value?: number; end_time?: string }[];
}[] = [];
for (const metrics of ['reach,follower_count', 'reach', 'follower_count']) {
const ins = await graphGet(`${encodeURIComponent(igId)}/insights`, agencyToken, {
metric: metrics,
period: 'day',
since: sinceU,
until: untilU,
});
if (ins.ok && Array.isArray(ins.json.data)) {
seriesData = ins.json.data as typeof seriesData;
break;
}
}
// total_value metrics (period totals + optional per-day series)
const totalMetrics = [
'views',
'profile_views',
'website_clicks',
'accounts_engaged',
'total_interactions',
];
let totalData: {
name?: string;
total_value?: { value?: number };
}[] = [];
{
const ins = await graphGet(`${encodeURIComponent(igId)}/insights`, agencyToken, {
metric: totalMetrics.join(','),
period: 'day',
metric_type: 'total_value',
since: sinceU,
until: untilU,
});
if (ins.ok && Array.isArray(ins.json.data)) {
totalData = ins.json.data as typeof totalData;
} else {
for (const metric of totalMetrics) {
const one = await graphGet(
`${encodeURIComponent(igId)}/insights`,
agencyToken,
{
metric,
period: 'day',
metric_type: 'total_value',
since: sinceU,
until: untilU,
}
);
if (one.ok && Array.isArray(one.json.data)) {
totalData.push(...(one.json.data as typeof totalData));
}
}
}
}
// Daily series for total_value metrics (Meta only returns a single total per range)
const days = eachDay(since, until).slice(-31);
const dailyMaps: Record<string, DayPoint[]> = {
views: [],
profile_views: [],
website_clicks: [],
accounts_engaged: [],
total_interactions: [],
};
for (let i = 0; i < days.length; i += 7) {
const chunk = days.slice(i, i + 7);
await Promise.all(
chunk.map(async (day) => {
const s = String(toUnix(day, false));
const u = String(toUnix(day, true));
const ins = await graphGet(
`${encodeURIComponent(igId)}/insights`,
agencyToken,
{
metric: totalMetrics.join(','),
period: 'day',
metric_type: 'total_value',
since: s,
until: u,
}
);
if (!ins.ok || !Array.isArray(ins.json.data)) return;
for (const row of ins.json.data as {
name?: string;
total_value?: { value?: number };
}[]) {
const name = row.name || '';
if (!(name in dailyMaps)) continue;
const v = row.total_value?.value;
if (typeof v !== 'number') continue;
dailyMaps[name].push({ day, value: v });
}
})
);
}
for (const k of Object.keys(dailyMaps)) {
dailyMaps[k].sort((a, b) => a.day.localeCompare(b.day));
}
const series = {
reach: insightSeries(seriesData, 'reach'),
impressions: dailyMaps.views,
profileViews: dailyMaps.profile_views,
websiteClicks: dailyMaps.website_clicks,
accountsEngaged: dailyMaps.accounts_engaged,
followerDelta: insightSeries(seriesData, 'follower_count'),
interactions: dailyMaps.total_interactions,
};
const igInsights = {
reach: sumSeries(series.reach) || lastValue(series.reach),
impressions:
totalValueOf(totalData, 'views') ??
(sumSeries(series.impressions) || lastValue(series.impressions)),
profileViews:
totalValueOf(totalData, 'profile_views') ??
(sumSeries(series.profileViews) || lastValue(series.profileViews)),
websiteClicks:
totalValueOf(totalData, 'website_clicks') ??
(sumSeries(series.websiteClicks) || lastValue(series.websiteClicks)),
accountsEngaged:
totalValueOf(totalData, 'accounts_engaged') ??
(sumSeries(series.accountsEngaged) || lastValue(series.accountsEngaged)),
followerDelta:
sumSeries(series.followerDelta) || lastValue(series.followerDelta),
interactions:
totalValueOf(totalData, 'total_interactions') ??
(sumSeries(series.interactions) || lastValue(series.interactions)),
};
const mediaRes = await graphGet(`${encodeURIComponent(igId)}/media`, agencyToken, {
fields:
'id,caption,media_type,media_product_type,media_url,thumbnail_url,permalink,timestamp,like_count,comments_count,children{id,media_type,media_url,thumbnail_url}',
limit: '30',
});
const posts: SocialPost[] = [];
let postsError: string | undefined;
if (mediaRes.ok) {
for (const m of (mediaRes.json.data as Record<string, unknown>[]) || []) {
const mediaType = (m.media_type as string) || null;
const mediaProductType = (m.media_product_type as string) || null;
const video = isVideoType(mediaType) || mediaProductType === 'REELS';
const childrenRaw =
(
m.children as {
data?: {
id?: string;
media_type?: string;
media_url?: string;
thumbnail_url?: string;
}[];
}
)?.data || [];
const children = childrenRaw.map((c) => ({
id: c.id,
mediaType: c.media_type || null,
mediaUrl: c.media_url || null,
thumbnailUrl: c.thumbnail_url || null,
}));
const mediaUrl = video
? (m.media_url as string) || (m.thumbnail_url as string) || null
: (m.media_url as string) ||
children[0]?.mediaUrl ||
(m.thumbnail_url as string) ||
null;
const thumbnailUrl =
(m.thumbnail_url as string) ||
(video ? null : (m.media_url as string)) ||
children[0]?.thumbnailUrl ||
children[0]?.mediaUrl ||
null;
let reach: number | null = null;
let impressions: number | null = null;
let shares = 0;
try {
const metrics = video
? 'reach,saved,shares,views'
: 'reach,views,saved,shares';
const mi = await graphGet(
`${encodeURIComponent(String(m.id))}/insights`,
agencyToken,
{ metric: metrics }
);
if (mi.ok && Array.isArray(mi.json.data)) {
const data = mi.json.data as {
name?: string;
values?: { value?: number }[];
}[];
const val = (name: string) => {
const row = data.find((d) => d.name === name);
const v = row?.values?.[0]?.value;
return typeof v === 'number' ? v : null;
};
reach = val('reach');
impressions = val('views') ?? val('impressions');
shares = val('shares') ?? 0;
}
} catch {
/* skip */
}
posts.push({
id: String(m.id),
platform: 'instagram',
message: String(m.caption || ''),
mediaUrl,
thumbnailUrl,
permalink: (m.permalink as string) || null,
createdAt: (m.timestamp as string) || null,
likes: Number(m.like_count || 0),
comments: Number(m.comments_count || 0),
shares,
reach,
impressions,
mediaType:
mediaProductType === 'REELS'
? 'REELS'
: mediaType || (children.length > 1 ? 'CAROUSEL_ALBUM' : null),
mediaProductType,
children: children.length ? children : undefined,
});
}
} else {
postsError = formatGraphError(mediaRes.raw);
}
return {
...profile,
igInsights,
series,
posts,
postsError,
insightsError: undefined,
since,
until,
};
}
function defaultRange(): { since: string; until: string } {
const until = new Date();
until.setUTCDate(until.getUTCDate() - 1);
const since = new Date(until);
since.setUTCDate(since.getUTCDate() - 27);
return {
since: since.toISOString().slice(0, 10),
until: until.toISOString().slice(0, 10),
};
}
/** GET — social integrations + analytics series + posts. ?since=&until= */
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({
integrations: [],
metrics: {},
posts: [],
warning: 'Selecciona un cliente en Contexto',
});
}
const def = defaultRange();
const sinceParam = req.nextUrl.searchParams.get('since') || '';
const untilParam = req.nextUrl.searchParams.get('until') || '';
const since = /^\d{4}-\d{2}-\d{2}$/.test(sinceParam) ? sinceParam : def.since;
const until = /^\d{4}-\d{2}-\d{2}$/.test(untilParam) ? untilParam : def.until;
const agency = isAgencyRole(userRole(user));
const providers = 'instagram,facebook,tiktok,linkedin';
let integrations: Integ[] = [];
if (agency && ctx.viaView) {
const tid = encodeURIComponent(ctx.tenantId);
const r = await restGet(
`/rest/v1/integrations?tenant_id=eq.${tid}&provider=in.(${providers})&select=provider,status,external_account_id,label,last_sync_at,meta,error_message`
);
integrations = (r.data as Integ[]) || [];
} else {
const { data } = await supabase
.from('integrations')
.select(
'provider,status,external_account_id,label,last_sync_at,meta,error_message'
)
.in('provider', ['instagram', 'facebook', 'tiktok', 'linkedin']);
integrations = (data as Integ[]) || [];
}
const metrics: Record<string, unknown> = {};
const posts: SocialPost[] = [];
const tokens = await resolveMetaAccessTokens();
const fbInteg = integrations.find(
(i) =>
i.provider === 'facebook' &&
i.status === 'connected' &&
i.external_account_id &&
!i.external_account_id.startsWith('http')
);
const igInteg = integrations.find(
(i) =>
i.provider === 'instagram' &&
i.status === 'connected' &&
i.external_account_id &&
!i.external_account_id.startsWith('http')
);
if (!tokens.length) {
metrics.warning =
'Sin token Meta: configura META_ACCESS_TOKEN (4V2030) o pégalo en Agencia.';
const cached = readSocialCache(integrations);
if (cached.facebook) metrics.facebook = cached.facebook;
if (cached.instagram) metrics.instagram = cached.instagram;
if (cached.facebook || cached.instagram) {
metrics.cached = true;
metrics.cachedAt = cached.at;
metrics.warning +=
' Mostrando último snapshot cacheado.';
}
} else {
const token =
(await pickWorkingToken(tokens, fbInteg?.external_account_id)) ||
(await pickWorkingToken(tokens, igInteg?.external_account_id)) ||
tokens[0];
try {
if (fbInteg?.external_account_id) {
const fb = await fetchPageBundle(
token,
fbInteg.external_account_id,
since,
until
);
metrics.facebook = fb;
if ('posts' in fb && Array.isArray(fb.posts)) {
posts.push(...(fb.posts as SocialPost[]));
}
await cacheSocialSnapshot(ctx.tenantId, 'facebook', fb);
}
if (igInteg?.external_account_id) {
const ig = await fetchIgBundle(
token,
igInteg.external_account_id,
since,
until
);
metrics.instagram = ig;
if ('posts' in ig && Array.isArray(ig.posts)) {
posts.push(...(ig.posts as SocialPost[]));
}
await cacheSocialSnapshot(ctx.tenantId, 'instagram', ig);
}
} catch (e) {
metrics.warning = e instanceof Error ? e.message : 'metrics error';
const cached = readSocialCache(integrations);
if (cached.facebook && !metrics.facebook) metrics.facebook = cached.facebook;
if (cached.instagram && !metrics.instagram)
metrics.instagram = cached.instagram;
if (cached.facebook || cached.instagram) {
metrics.cached = true;
metrics.cachedAt = cached.at;
}
}
}
// Filter posts to selected range when dates exist
const ranged = posts.filter((p) => {
if (!p.createdAt) return true;
const d = p.createdAt.slice(0, 10);
return d >= since && d <= until;
});
ranged.sort((a, b) => {
const ta = Date.parse(a.createdAt || '') || 0;
const tb = Date.parse(b.createdAt || '') || 0;
return tb - ta;
});
return NextResponse.json({
integrations,
metrics,
posts: ranged.slice(0, 24),
since,
until,
label: ctx.label,
});
}

View File

@@ -0,0 +1,451 @@
import { NextRequest, NextResponse } from 'next/server';
import { createHash, randomUUID } from 'crypto';
import { serviceHeaders, supabaseUrl } from '@/lib/agency';
export const dynamic = 'force-dynamic';
/**
* Clientify outbound webhooks.
*
* Paste in Clientify → Integraciones → Webhooks (per subcuenta):
* https://app.4ventures.es/api/webhooks/clientify?tenant=agua-miia
*
* Optional secret (same for all entities):
* &secret=YOUR_SECRET (must match CLIENTIFY_WEBHOOK_SECRET)
*
* Entity-specific (optional, inferred if omitted):
* &entity=contact|company|deal|activity|budget|product
*/
type Json = Record<string, unknown>;
function n(v: unknown): number {
const x = Number(v);
return Number.isFinite(x) ? x : 0;
}
function str(v: unknown, max = 200): string | null {
if (v == null) return null;
if (typeof v === 'object' && v !== null && 'name' in (v as object)) {
return String((v as { name?: unknown }).name || '').slice(0, max) || null;
}
const s = String(v).trim();
return s ? s.slice(0, max) : null;
}
function uuid5(name: string): string {
// RFC 4122 UUID v5 with URL namespace (same as Python uuid.uuid5(NAMESPACE_URL, …))
const NS = Buffer.from('6ba7b8119dad11d180b400c04fd430c8', 'hex');
const hash = createHash('sha1').update(NS).update(name).digest();
hash[6] = (hash[6]! & 0x0f) | 0x50;
hash[8] = (hash[8]! & 0x3f) | 0x80;
const h = hash.subarray(0, 16).toString('hex');
return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
}
function firstEmail(c: Json): string | null {
const emails = c.emails;
if (Array.isArray(emails) && emails.length) {
const e = emails[0] as Json;
return str(typeof e === 'object' ? e.email || e.value : e, 200);
}
return str(c.email, 200);
}
function firstPhone(c: Json): string | null {
const phones = c.phones;
if (Array.isArray(phones) && phones.length) {
const p = phones[0] as Json;
return str(typeof p === 'object' ? p.phone || p.value : p, 60);
}
return str(c.phone, 60);
}
function contactIdFromUrl(url: unknown): string | null {
const s = str(url, 300);
if (!s) return null;
const parts = s.replace(/\/$/, '').split('/');
return parts[parts.length - 1] || null;
}
function mapDealStage(d: Json): string {
const desc = (str(d.status_desc, 40) || '').toLowerCase();
if (desc === 'won') return 'won';
if (desc === 'lost') return 'lost';
if (desc === 'open') return 'open';
const st = n(d.status);
if (st === 3) return 'won';
if (st === 4) return 'lost';
return 'open';
}
function unwrapPayload(body: unknown): Json {
if (!body || typeof body !== 'object') return {};
const b = body as Json;
// Common wrappers
for (const k of ['contact', 'deal', 'company', 'data', 'object', 'payload']) {
const inner = b[k];
if (inner && typeof inner === 'object' && !Array.isArray(inner)) {
return inner as Json;
}
}
return b;
}
function guessEntity(q: string | null, payload: Json): string {
if (q) return q.toLowerCase();
if (payload.pipeline != null || payload.pipeline_stage != null || payload.amount != null)
return 'deal';
if (payload.first_name != null || payload.emails != null || payload.phones != null)
return 'contact';
if (payload.company_sector != null || (payload.name != null && payload.domain != null))
return 'company';
return 'unknown';
}
async function resolveTenantId(slugOrId: string | null): Promise<string | null> {
if (!slugOrId) return null;
const headers = serviceHeaders();
if (!headers) return null;
const base = supabaseUrl();
// UUID?
if (/^[0-9a-f-]{36}$/i.test(slugOrId)) return slugOrId;
// Legacy slug aliases (renames)
const SLUG_ALIASES: Record<string, string> = {
herencias: 'fbm-abogados',
};
const slug = SLUG_ALIASES[slugOrId.toLowerCase()] || slugOrId;
const r = await fetch(
`${base}/rest/v1/tenants?slug=eq.${encodeURIComponent(slug)}&select=id&limit=1`,
{ headers, cache: 'no-store' }
);
if (!r.ok) return null;
const rows = (await r.json()) as { id: string }[];
return rows[0]?.id || null;
}
async function upsertLead(tenantId: string, c: Json) {
const headers = serviceHeaders();
if (!headers) throw new Error('no service role');
const cid = str(c.id, 40);
if (!cid) throw new Error('contact missing id');
const id = uuid5(`clientify-lead:${tenantId}:${cid}`);
const source =
str(c.contact_source, 80) ||
str(c.channel, 80) ||
str(c.medium, 80) ||
'clientify';
const newStatus = (str(c.status, 40) || 'new').toLowerCase();
const now = new Date().toISOString();
const base = supabaseUrl();
// Preserve alta date across webhook re-writes (DELETE+INSERT).
let prevCreated: string | null = null;
let prevStatus: string | null = null;
let prevStatusChanged: string | null = null;
const prevR = await fetch(
`${base}/rest/v1/leads?tenant_id=eq.${encodeURIComponent(tenantId)}&crm=eq.clientify&crm_contact_id=eq.${encodeURIComponent(cid)}&select=created_at,status,status_changed_at&limit=1`,
{ headers, cache: 'no-store' }
);
if (prevR.ok) {
const rows = (await prevR.json()) as {
created_at?: string;
status?: string;
status_changed_at?: string | null;
}[];
if (rows[0]) {
prevCreated = rows[0].created_at || null;
prevStatus = (rows[0].status || '').toLowerCase() || null;
prevStatusChanged = rows[0].status_changed_at || null;
}
} else {
// status_changed_at may not exist yet — retry without it
const prevR2 = await fetch(
`${base}/rest/v1/leads?tenant_id=eq.${encodeURIComponent(tenantId)}&crm=eq.clientify&crm_contact_id=eq.${encodeURIComponent(cid)}&select=created_at,status&limit=1`,
{ headers, cache: 'no-store' }
);
if (prevR2.ok) {
const rows = (await prevR2.json()) as {
created_at?: string;
status?: string;
}[];
if (rows[0]) {
prevCreated = rows[0].created_at || null;
prevStatus = (rows[0].status || '').toLowerCase() || null;
}
}
}
const fromCfy =
str(c.created, 40) ||
str(c.created_at, 40) ||
str(c.date_created, 40);
let createdAt = prevCreated || fromCfy || now;
// Normalize date-only
if (createdAt.length === 10) createdAt = `${createdAt}T12:00:00.000Z`;
const statusChanged =
!prevStatus || prevStatus !== newStatus ? now : prevStatusChanged || createdAt;
const row: Record<string, unknown> = {
id,
tenant_id: tenantId,
first_name:
[str(c.first_name, 80), str(c.last_name, 80)].filter(Boolean).join(' ') ||
null,
email: firstEmail(c),
phone: firstPhone(c),
source: source.toLowerCase(),
medium: str(c.medium, 80),
crm: 'clientify',
crm_contact_id: cid,
status: newStatus,
created_at: createdAt,
updated_at: now,
status_changed_at: statusChanged,
};
await fetch(
`${base}/rest/v1/leads?tenant_id=eq.${encodeURIComponent(tenantId)}&crm=eq.clientify&crm_contact_id=eq.${encodeURIComponent(cid)}`,
{ method: 'DELETE', headers: { ...headers, Prefer: 'return=minimal' } }
);
let ins = await fetch(`${base}/rest/v1/leads`, {
method: 'POST',
headers: {
...headers,
Prefer: 'return=minimal',
'Content-Type': 'application/json',
},
body: JSON.stringify(row),
});
// Schema not migrated yet: drop timing columns and retry
if (!ins.ok) {
const err = await ins.text();
if (err.includes('updated_at') || err.includes('status_changed_at')) {
delete row.updated_at;
delete row.status_changed_at;
ins = await fetch(`${base}/rest/v1/leads`, {
method: 'POST',
headers: {
...headers,
Prefer: 'return=minimal',
'Content-Type': 'application/json',
},
body: JSON.stringify(row),
});
} else {
throw new Error(`lead insert ${ins.status}: ${err.slice(0, 200)}`);
}
}
if (!ins.ok) {
throw new Error(`lead insert ${ins.status}: ${(await ins.text()).slice(0, 200)}`);
}
return { id, crm_contact_id: cid };
}
async function upsertDeal(tenantId: string, d: Json) {
const headers = serviceHeaders();
if (!headers) throw new Error('no service role');
const did = str(d.id, 40);
if (!did) throw new Error('deal missing id');
const id = uuid5(`clientify-deal:${tenantId}:${did}`);
const stage = mapDealStage(d);
const cid = contactIdFromUrl(d.contact);
let leadId: string | null = null;
if (cid) {
const base = supabaseUrl();
const lr = await fetch(
`${base}/rest/v1/leads?tenant_id=eq.${encodeURIComponent(tenantId)}&crm=eq.clientify&crm_contact_id=eq.${encodeURIComponent(cid)}&select=id&limit=1`,
{ headers, cache: 'no-store' }
);
if (lr.ok) {
const rows = (await lr.json()) as { id: string }[];
leadId = rows[0]?.id || null;
}
if (!leadId) {
leadId = uuid5(`clientify-lead:${tenantId}:${cid}`);
}
}
const closed = str(d.actual_closed_date, 40);
const pipeUrl = str(d.pipeline, 200) || '';
let pipelineId: string | null = null;
if (pipeUrl.includes('/pipelines/')) {
pipelineId = pipeUrl.replace(/\/$/, '').split('/').pop() || null;
}
const tags = Array.isArray(d.tags) ? d.tags.map(String) : [];
const tagBlob = tags.join(' ').toLowerCase();
const dealName = str(d.name, 200) || '';
let source =
str(d.deal_source, 80) ||
str(d.contact_source, 80) ||
'';
if (!source) {
if (/facebook|instagram|\bmeta\b|\bfb\b/.test(tagBlob)) source = 'meta';
else if (/google/.test(tagBlob)) source = 'google';
else if (
/woocommerce|ecommerce/.test(tagBlob) ||
/^order\s+\d+/i.test(dealName)
) {
source = 'ecommerce';
}
}
const row = {
id,
tenant_id: tenantId,
lead_id: leadId,
crm_deal_id: `clientify:${did}`,
stage,
amount: n(d.amount ?? d.amount_user),
won_at: stage === 'won' && closed ? closed : null,
lost_at: stage === 'lost' && closed ? closed : null,
pipeline_id: pipelineId,
pipeline_name: str(d.pipeline_desc, 120) || null,
source: source ? source.toLowerCase() : null,
name: dealName || null,
};
const base = supabaseUrl();
await fetch(
`${base}/rest/v1/deals?tenant_id=eq.${encodeURIComponent(tenantId)}&crm_deal_id=eq.${encodeURIComponent(`clientify:${did}`)}`,
{ method: 'DELETE', headers: { ...headers, Prefer: 'return=minimal' } }
);
const ins = await fetch(`${base}/rest/v1/deals`, {
method: 'POST',
headers: { ...headers, Prefer: 'return=minimal', 'Content-Type': 'application/json' },
body: JSON.stringify(row),
});
if (!ins.ok) {
throw new Error(`deal insert ${ins.status}: ${(await ins.text()).slice(0, 200)}`);
}
return { id, crm_deal_id: row.crm_deal_id, stage };
}
export async function GET(req: NextRequest) {
const tenant = req.nextUrl.searchParams.get('tenant');
return NextResponse.json({
ok: true,
service: 'clientify-webhook',
tenant: tenant || null,
hint: 'POST events from Clientify. Use ?tenant=<slug>',
example: 'https://app.4ventures.es/api/webhooks/clientify?tenant=<slug>&secret=…',
});
}
export async function POST(req: NextRequest) {
const secretEnv = process.env.CLIENTIFY_WEBHOOK_SECRET?.trim();
const secretQ = req.nextUrl.searchParams.get('secret');
const secretH = req.headers.get('x-clientify-secret');
// Obligatorio: sin secreto el webhook es inyectable desde fuera
if (!secretEnv) {
return NextResponse.json(
{
error: 'webhook_not_configured',
hint: 'Define CLIENTIFY_WEBHOOK_SECRET en Dokploy',
},
{ status: 503 }
);
}
if (secretQ !== secretEnv && secretH !== secretEnv) {
return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
}
const tenantParam =
req.nextUrl.searchParams.get('tenant') ||
req.nextUrl.searchParams.get('tenant_id');
const tenantId = await resolveTenantId(tenantParam);
if (!tenantId) {
return NextResponse.json(
{
error: 'missing_or_unknown_tenant',
hint: 'Add ?tenant=<slug>&secret=… to the webhook URL',
},
{ status: 400 }
);
}
let body: unknown;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: 'invalid_json' }, { status: 400 });
}
const payload = unwrapPayload(body);
const entity = guessEntity(
req.nextUrl.searchParams.get('entity'),
payload
);
try {
if (entity === 'contact') {
const r = await upsertLead(tenantId, payload);
await markClientifyConnected(tenantId, tenantParam);
return NextResponse.json({ ok: true, entity, ...r });
}
if (entity === 'deal') {
const r = await upsertDeal(tenantId, payload);
await markClientifyConnected(tenantId, tenantParam);
return NextResponse.json({ ok: true, entity, ...r });
}
// company/activity/budget/product: acknowledge for now
return NextResponse.json({
ok: true,
entity,
ignored: true,
id: payload.id ?? randomUUID(),
hint: 'contact/deal only persisted in v1',
});
} catch (e) {
const msg = e instanceof Error ? e.message : 'error';
return NextResponse.json({ error: msg }, { status: 500 });
}
}
/** Webhook escribe leads pero la ficha quedaba "disconnected" — alinear status. */
async function markClientifyConnected(
tenantId: string,
slugHint: string | null
) {
const headers = serviceHeaders();
if (!headers) return;
const base = supabaseUrl();
const now = new Date().toISOString();
const body = {
status: 'connected',
enabled: true,
last_sync_at: now,
error_message: null,
label: 'Clientify',
external_account_id: slugHint || undefined,
meta: {
source: 'webhook',
synced_at: now,
},
};
const patch = await fetch(
`${base}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(tenantId)}&provider=eq.clientify`,
{
method: 'PATCH',
headers: {
...headers,
'Content-Type': 'application/json',
Prefer: 'return=minimal',
},
body: JSON.stringify(body),
}
);
if (patch.ok) return;
await fetch(`${base}/rest/v1/integrations`, {
method: 'POST',
headers: {
...headers,
'Content-Type': 'application/json',
Prefer: 'resolution=ignore-duplicates,return=minimal',
},
body: JSON.stringify({
tenant_id: tenantId,
provider: 'clientify',
...body,
}),
});
}

View File

@@ -1,93 +1,36 @@
import React from 'react';
import { FileText, CheckCircle2 } from 'lucide-react';
import { createClient } from '@/lib/supabase/server';
import { redirect } from 'next/navigation';
import { resolveActiveTenant } from '@/lib/tenant-context';
import AssetsLibrary from '@/components/AssetsLibrary';
import { EmptyState, PageHeader } from '@/components/ui';
export const revalidate = 0;
export const dynamic = 'force-dynamic';
export default async function ActivosPage() {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) redirect('/login');
let documents: Array<{
id: string;
type?: string;
title?: string;
content?: string;
version?: number;
created_at?: string;
status?: string;
}> = [];
const ctx = await resolveActiveTenant(user);
if (user) {
// Relies on RLS: only documents for the caller's tenant
const { data, error } = await supabase
.from('documents')
.select('id,type,title,content,version,created_at,status')
.eq('status', 'approved')
.order('created_at', { ascending: false });
if (error) {
console.error('Error fetching documents:', error.message);
} else {
documents = data ?? [];
}
if (ctx.portfolioWide) {
return (
<div className="space-y-6">
<PageHeader
eyebrow="Contenido"
title="Activos"
description="Biblioteca de archivos validados por cliente."
/>
<EmptyState
title="Elige un cliente"
description="Selecciona un tenant en Contexto para subir o consultar archivos."
/>
</div>
);
}
return (
<div className="space-y-8">
<div>
<h2 className="text-2xl font-bold tracking-tight text-white">
Biblioteca de Activos & Entregables
</h2>
<p className="text-sm text-gray-400 mt-1">
Documentos, copys, calendarios y briefs producidos por los agentes de IA
y aprobados por el equipo en Slack. Filtrado por tenant (RLS).
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{documents.length === 0 ? (
<div className="col-span-3 bg-[#111827] border border-gray-800 rounded-xl p-12 text-center text-gray-400">
<FileText className="w-10 h-10 text-gray-600 mx-auto mb-3" />
<p className="text-base font-medium text-gray-300">
No hay activos aprobados todavía.
</p>
<p className="text-xs text-gray-400 mt-1">
Los activos generados por los agentes aparecerán aquí tras ser
validados en Slack.
</p>
</div>
) : (
documents.map((doc) => (
<div
key={doc.id}
className="bg-[#111827] border border-gray-800 rounded-xl p-5 flex flex-col justify-between"
>
<div>
<div className="flex items-center justify-between mb-3">
<span className="uppercase text-[10px] font-bold px-2 py-0.5 rounded bg-gray-800 text-gray-300">
{doc.type}
</span>
<span className="flex items-center gap-1 text-[11px] text-emerald-400">
<CheckCircle2 className="w-3 h-3" /> Aprobado
</span>
</div>
<h4 className="font-bold text-white text-base mb-2">{doc.title}</h4>
<p className="text-xs text-gray-400 line-clamp-4">{doc.content}</p>
</div>
<div className="pt-4 border-t border-gray-800/80 mt-4 text-[11px] text-gray-400 flex justify-between">
<span>Versión v{doc.version}</span>
<span>
{doc.created_at
? new Date(doc.created_at).toLocaleDateString('es-ES')
: '—'}
</span>
</div>
</div>
))
)}
</div>
</div>
);
return <AssetsLibrary tenantLabel={ctx.label} />;
}

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