diff --git a/docs/OPERATOR.md b/docs/OPERATOR.md index 1adfedb..37fe88f 100644 --- a/docs/OPERATOR.md +++ b/docs/OPERATOR.md @@ -8,7 +8,8 @@ - `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: panel **Google orgánico** → Descubrir/emparejar (sync automático). Orgánico se refresca al abrir la vista si faltan datos / sync >6h. +- 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). diff --git a/src/app/api/admin/drive/callback/route.ts b/src/app/api/admin/drive/callback/route.ts index 7ec90b3..50db51a 100644 --- a/src/app/api/admin/drive/callback/route.ts +++ b/src/app/api/admin/drive/callback/route.ts @@ -1,16 +1,15 @@ import { NextRequest, NextResponse } from 'next/server'; import { createClient } from '@/lib/supabase/server'; -import { isAgencyRole, serviceHeaders, supabaseUrl, userRole } from '@/lib/agency'; +import { isAgencyRole, userRole } from '@/lib/agency'; import { - applyMatches, exchangeCode, fetchGoogleEmail, - listDriveFolders, - matchFoldersToTenants, 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 ( @@ -19,7 +18,7 @@ function appUrl(): string { ); } -/** GET — OAuth callback: save tokens + auto-match folders → tenants. */ +/** GET — OAuth callback: save tokens + auto-emparejar Drive/GA4/GSC/Ads → fichas. */ export async function GET(req: NextRequest) { const supabase = await createClient(); const { @@ -56,35 +55,27 @@ export async function GET(req: NextRequest) { email, }); - // Auto-match folders (non-fatal: tokens already saved) let linked = 0; let ambiguous = 0; + let bootstrapMsg = ''; try { - const headers = serviceHeaders(); - if (headers) { - const tRes = await fetch( - `${supabaseUrl()}/rest/v1/tenants?select=id,name,slug&order=name.asc`, - { headers, cache: 'no-store' } - ); - if (tRes.ok) { - const tenants = (await tRes.json()) as { - id: string; - name: string; - slug: string; - }[]; - const folders = await listDriveFolders(); - const matches = await matchFoldersToTenants(tenants, folders); - const applied = await applyMatches(matches); - linked = applied.linked; - ambiguous = matches.filter((m) => m.status === 'ambiguous').length; - } - } - } catch { - // match can fail without undoing OAuth + 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}` + `${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; diff --git a/src/app/api/admin/google/organic/route.ts b/src/app/api/admin/google/organic/route.ts index 70ec94f..9779c84 100644 --- a/src/app/api/admin/google/organic/route.ts +++ b/src/app/api/admin/google/organic/route.ts @@ -7,6 +7,7 @@ import { userRole, } from '@/lib/agency'; import { getAgencyOAuthStatus } from '@/lib/google-drive'; +import { bootstrapAgencyGoogle } from '@/lib/agency-google-bootstrap'; import { applyOrganicMatches, listGa4Properties, @@ -90,7 +91,8 @@ export async function GET() { /** * POST - * - { action: 'match', apply?: boolean } → match + optional write integrations + * - { 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) { @@ -101,17 +103,38 @@ export async function POST(req: NextRequest) { }; const body = await req.json().catch(() => ({})); - const action = body.action === 'sync' ? 'sync' : 'match'; + 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', - }), + fetch( + `${supabaseUrl()}/rest/v1/tenants?select=id,name,slug&order=name.asc`, + { headers, cache: 'no-store' } + ), ]); if (!tRes.ok) { return NextResponse.json( @@ -155,7 +178,6 @@ export async function POST(req: NextRequest) { } } - // sync — rolling window; default 28 on first fill / manual force const days = Math.min(90, Math.max(3, Number(body.days) || 28)); const tenantFilter = typeof body.tenantId === 'string' ? body.tenantId.trim() : ''; diff --git a/src/components/AdsDiscoverPanel.tsx b/src/components/AdsDiscoverPanel.tsx index d5953bb..4844e21 100644 --- a/src/components/AdsDiscoverPanel.tsx +++ b/src/components/AdsDiscoverPanel.tsx @@ -86,7 +86,11 @@ export default function AdsDiscoverPanel() { const parts = []; if (data.meta) { parts.push( - `Meta: ${data.meta.linked}/${data.meta.accounts} enlazadas` + + `Meta Ads: ${data.meta.linked}/${data.meta.accounts}` + + (data.meta.pagesLinked + ? ` · FB ${data.meta.pagesLinked}` + : '') + + (data.meta.igLinked ? ` · IG ${data.meta.igLinked}` : '') + (data.meta.error ? ` (${data.meta.error})` : '') ); } @@ -179,9 +183,9 @@ export default function AdsDiscoverPanel() { Meta Ads + Google Ads (agencia)
- Lista cuentas accesibles y las enlaza a cada cliente por nombre. - Google Ads ya puede emparejarse; Meta cuando tengas el token de - App Review. + Lista cuentas accesibles y las enlaza a cada ficha por nombre. + Meta Ads + páginas FB/IG usan el token system user (API 4V2030 / + App Review). Google Ads con el MCC de agencia.
diff --git a/src/components/DriveConnectPanel.tsx b/src/components/DriveConnectPanel.tsx index 679a918..e0f9f08 100644 --- a/src/components/DriveConnectPanel.tsx +++ b/src/components/DriveConnectPanel.tsx @@ -37,11 +37,14 @@ export default function DriveConnectPanel() { useEffect(() => { const params = new URLSearchParams(window.location.search); if (params.get('drive') === 'ok') { + const boot = params.get('boot'); setMsg( - `Google conectado. Enlazadas ${params.get('linked') || 0} carpetas Drive` + - (params.get('ambiguous') && params.get('ambiguous') !== '0' - ? ` · ${params.get('ambiguous')} ambiguas (revisa abajo)` - : '') + boot + ? `Google conectado · ${boot}` + : `Google conectado. Enlazadas ${params.get('linked') || 0} carpetas Drive` + + (params.get('ambiguous') && params.get('ambiguous') !== '0' + ? ` · ${params.get('ambiguous')} ambiguas` + : '') ); } if (params.get('drive') === 'error') { @@ -101,7 +104,8 @@ export default function DriveConnectPanel() { Una sola conexión con{' '} marketing@4ventures.es. En el consentimiento acepta Drive, Analytics y Search Console. - Sirve para carpetas Drive y para Orgánico (panel de abajo). + Sirve para carpetas Drive, GA4, GSC y (con MCC) Google Ads. + Tras el consentimiento se emparejan solos a las fichas. diff --git a/src/components/GoogleOrganicPanel.tsx b/src/components/GoogleOrganicPanel.tsx index 0944d12..43ee1f3 100644 --- a/src/components/GoogleOrganicPanel.tsx +++ b/src/components/GoogleOrganicPanel.tsx @@ -54,40 +54,36 @@ export default function GoogleOrganicPanel() { useEffect(() => { const params = new URLSearchParams(window.location.search); if (params.get('drive') === 'ok') { + const boot = params.get('boot'); setMsg( - 'Google reconectado. Pulsa «Descubrir y emparejar» para GA4/GSC.' + boot + ? `Google reconectado · ${boot}` + : 'Google reconectado. Emparejando fichas…' ); } void loadStatus(); }, []); - async function discover(apply: boolean) { + async function bootstrapAll() { setBusy('match'); setErr(null); const res = await fetch('/api/admin/google/organic', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ action: 'match', apply }), + body: JSON.stringify({ action: 'bootstrap', days: 28 }), }); const data = await res.json(); - if (!res.ok) { - setBusy(null); - setErr(data.error || 'Discover falló'); + setBusy(null); + if (!res.ok && !data.message) { + setErr(data.error || 'Emparejado falló'); return; } - setMatches(data.matches || []); - setGa4Count(data.ga4Count || 0); - setGscCount(data.gscCount || 0); - setMsg( - apply - ? `Emparejado · GA4 ${data.applied?.ga4 || 0} · GSC ${data.applied?.gsc || 0}. Actualizando datos…` - : `Vistas ${data.ga4Count} props GA4 · ${data.gscCount} sites GSC (sin escribir)` - ); - if (apply) { - await syncAll(); - } else { - setBusy(null); + setMsg(data.message || 'Emparejado listo'); + if (data.organic) { + setGa4Count(data.organic.ga4Visible || 0); + setGscCount(data.organic.gscVisible || 0); } + await loadStatus(); } async function syncAll() { @@ -109,7 +105,7 @@ export default function GoogleOrganicPanel() { 0 ); setMsg( - `Datos al día · ${data.synced || 0} clientes · ${wrote} días en Orgánico (se actualiza solo al abrir la vista y cada noche)` + `Datos al día · ${data.synced || 0} clientes · ${wrote} días en Orgánico` ); } @@ -128,12 +124,12 @@ export default function GoogleOrganicPanel() {- No es otra cuenta: usa la conexión del panel 1. Aquí solo - emparejas propiedades/sites a cada cliente y actualizas datos. - Luego se mantienen al día solos. + Al conectar Google (panel 1) se rellenan solos Drive, GA4, GSC y + Google Ads en cada ficha. Aquí puedes repetir el emparejado y + actualizar Orgánico.