From d137b5796ad14a033ffe12023f2953014312d508 Mon Sep 17 00:00:00 2001 From: 4Ventures <4ventures@MacBook-Air-de-4Ventures.local> Date: Thu, 24 Sep 2026 12:10:02 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Org=C3=A1nico=20siempre=20al=20d=C3=ADa?= =?UTF-8?q?=20(auto-sync=20al=20abrir=20+=20cron=20diario)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/OPERATOR.md | 5 +- src/app/api/admin/google/organic/route.ts | 94 +++----------- src/app/api/cron/organic-sync/route.ts | 56 +++++++++ src/app/api/organico/route.ts | 31 ++++- src/components/GoogleOrganicPanel.tsx | 18 ++- src/components/OrganicoDashboard.tsx | 5 + src/lib/google-organic.ts | 146 ++++++++++++++++++++++ 7 files changed, 267 insertions(+), 88 deletions(-) create mode 100644 src/app/api/cron/organic-sync/route.ts diff --git a/docs/OPERATOR.md b/docs/OPERATOR.md index 0ccb729..1adfedb 100644 --- a/docs/OPERATOR.md +++ b/docs/OPERATOR.md @@ -8,7 +8,10 @@ - `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 28d a `organic_daily`. +- 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. +- 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. diff --git a/src/app/api/admin/google/organic/route.ts b/src/app/api/admin/google/organic/route.ts index da6b9d6..70ec94f 100644 --- a/src/app/api/admin/google/organic/route.ts +++ b/src/app/api/admin/google/organic/route.ts @@ -12,7 +12,7 @@ import { listGa4Properties, listGscSites, matchOrganicToTenants, - syncOrganicTenant, + syncAllConnectedOrganic, } from '@/lib/google-organic'; export const dynamic = 'force-dynamic'; @@ -155,85 +155,21 @@ export async function POST(req: NextRequest) { } } - // sync - const days = Math.min(90, Math.max(7, Number(body.days) || 28)); - let tenantFilter = + // 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() : ''; - const iRes = await fetch( - `${supabaseUrl()}/rest/v1/integrations?provider=in.(ga4,search_console)&status=eq.connected&select=tenant_id,provider,external_account_id,meta`, - { headers, cache: 'no-store' } - ); - if (!iRes.ok) { - return NextResponse.json({ error: await iRes.text() }, { status: 500 }); + 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 } + ); } - const integs = (await iRes.json()) as { - tenant_id: string; - provider: string; - external_account_id?: string; - meta?: { property_id?: string; measurement_id?: string; site_url?: string }; - }[]; - - const byTenant = new Map< - string, - { ga4PropertyId?: string; gscSiteUrl?: string } - >(); - for (const i of integs) { - if (tenantFilter && i.tenant_id !== tenantFilter) continue; - const cur = byTenant.get(i.tenant_id) || {}; - if (i.provider === 'ga4') { - cur.ga4PropertyId = - i.meta?.property_id || - (i.external_account_id?.startsWith('properties/') - ? i.external_account_id - : undefined); - } - if (i.provider === 'search_console') { - cur.gscSiteUrl = i.meta?.site_url || i.external_account_id || undefined; - } - byTenant.set(i.tenant_id, cur); - } - - // If ga4 only has G- measurement id, resolve property from discover list - let ga4Catalog: Awaited> | null = null; - const results: { - tenantId: string; - daysWritten: number; - warning?: string; - }[] = []; - - for (const [tenantId, cfg] of byTenant) { - let propId = cfg.ga4PropertyId; - if (!propId && integs.some((i) => i.tenant_id === tenantId && i.provider === 'ga4')) { - const mid = integs.find( - (i) => i.tenant_id === tenantId && i.provider === 'ga4' - )?.external_account_id; - if (mid?.startsWith('G-')) { - if (!ga4Catalog) ga4Catalog = await listGa4Properties(); - const hit = ga4Catalog.find((p) => p.measurementId === mid); - propId = hit?.propertyId; - } - } - if (!propId && !cfg.gscSiteUrl) continue; - try { - const r = await syncOrganicTenant({ - tenantId, - ga4PropertyId: propId, - gscSiteUrl: cfg.gscSiteUrl, - days, - }); - results.push({ tenantId, ...r }); - } catch (e) { - results.push({ - tenantId, - daysWritten: 0, - warning: e instanceof Error ? e.message : 'sync error', - }); - } - } - - return NextResponse.json({ - synced: results.filter((r) => r.daysWritten > 0).length, - results, - }); } diff --git a/src/app/api/cron/organic-sync/route.ts b/src/app/api/cron/organic-sync/route.ts new file mode 100644 index 0000000..b25c0db --- /dev/null +++ b/src/app/api/cron/organic-sync/route.ts @@ -0,0 +1,56 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { syncAllConnectedOrganic } from '@/lib/google-organic'; +import { getAgencyOAuthStatus } from '@/lib/google-drive'; + +export const dynamic = 'force-dynamic'; +export const maxDuration = 300; + +/** + * Daily (or any schedule) organic sync for all connected tenants. + * Auth: Authorization: Bearer $CRON_SECRET OR ?secret=$CRON_SECRET + * Dokploy cron example (daily 06:15 UTC): + * curl -sS -H "Authorization: Bearer $CRON_SECRET" https://app.4ventures.es/api/cron/organic-sync + */ +export async function GET(req: NextRequest) { + const secret = process.env.CRON_SECRET?.trim(); + const auth = req.headers.get('authorization') || ''; + const q = req.nextUrl.searchParams.get('secret') || ''; + const token = auth.startsWith('Bearer ') ? auth.slice(7) : q; + + if (!secret || token !== secret) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const status = await getAgencyOAuthStatus(); + if (!status.connected) { + return NextResponse.json({ + ok: false, + warning: 'Google agencia no conectado', + }); + } + + // Rolling 7 days is enough daily; full history already filled by first sync + 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); +} diff --git a/src/app/api/organico/route.ts b/src/app/api/organico/route.ts index 8bc1420..5e3089d 100644 --- a/src/app/api/organico/route.ts +++ b/src/app/api/organico/route.ts @@ -8,8 +8,14 @@ import { } from '@/lib/agency'; import { resolveActiveTenant } from '@/lib/tenant-context'; import type { GeoSnapshot, OrganicDailyRow } from '@/lib/organic'; +import { + organicNeedsRefresh, + syncAllConnectedOrganic, +} from '@/lib/google-organic'; +import { getAgencyOAuthStatus } from '@/lib/google-drive'; export const dynamic = 'force-dynamic'; +export const maxDuration = 120; async function gate() { const supabase = await createClient(); @@ -47,6 +53,25 @@ export async function GET(req: NextRequest) { 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(); @@ -109,7 +134,8 @@ export async function GET(req: NextRequest) { geo, integrations, workspace, - warning: warningParts[0] || null, + autoSynced, + warning: warningParts[0] || autoSynced?.warning || null, }); } @@ -144,7 +170,8 @@ export async function GET(req: NextRequest) { geo: ge.data || [], integrations: integ.data || [], workspace: (ten.data?.settings as { organico?: unknown } | null)?.organico || {}, - warning: o.error?.message || ge.error?.message || null, + autoSynced, + warning: o.error?.message || ge.error?.message || autoSynced?.warning || null, }); } diff --git a/src/components/GoogleOrganicPanel.tsx b/src/components/GoogleOrganicPanel.tsx index 045171f..05fe358 100644 --- a/src/components/GoogleOrganicPanel.tsx +++ b/src/components/GoogleOrganicPanel.tsx @@ -70,8 +70,8 @@ export default function GoogleOrganicPanel() { body: JSON.stringify({ action: 'match', apply }), }); const data = await res.json(); - setBusy(null); if (!res.ok) { + setBusy(null); setErr(data.error || 'Discover falló'); return; } @@ -80,9 +80,14 @@ export default function GoogleOrganicPanel() { setGscCount(data.gscCount || 0); setMsg( apply - ? `Emparejado · GA4 ${data.applied?.ga4 || 0} · GSC ${data.applied?.gsc || 0} (de ${data.ga4Count} props / ${data.gscCount} sites)` + ? `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); + } } async function syncAll() { @@ -96,7 +101,7 @@ export default function GoogleOrganicPanel() { const data = await res.json(); setBusy(null); if (!res.ok) { - setErr(data.error || 'Sync falló'); + setErr(data.error || 'Actualización falló'); return; } const wrote = (data.results || []).reduce( @@ -104,7 +109,7 @@ export default function GoogleOrganicPanel() { 0 ); setMsg( - `Sync orgánico: ${data.synced || 0} clientes · ${wrote} días escritos en organic_daily` + `Datos al día · ${data.synced || 0} clientes · ${wrote} días en Orgánico (se actualiza solo al abrir la vista y cada noche)` ); } @@ -129,7 +134,8 @@ export default function GoogleOrganicPanel() { Usa el mismo OAuth de{' '} marketing@4ventures.es{' '} (Drive + Analytics + GSC). Empareja propiedades/sites a fichas y - sincroniza a Orgánico. + sincroniza a Orgánico. Los datos se mantienen al día solos (al abrir + la vista + cron diario).

@@ -164,7 +170,7 @@ export default function GoogleOrganicPanel() { ) : ( )} - Sync 28d → Orgánico + Actualizar ahora diff --git a/src/components/OrganicoDashboard.tsx b/src/components/OrganicoDashboard.tsx index 00dfe4f..4f8c38e 100644 --- a/src/components/OrganicoDashboard.tsx +++ b/src/components/OrganicoDashboard.tsx @@ -135,6 +135,11 @@ export default function OrganicoDashboard({ setIntegrations(data.integrations || []); setWorkspace(data.workspace || {}); setWarning(data.warning || null); + if (data.autoSynced?.daysWritten) { + setMsg( + `Datos GA4/GSC actualizados automáticamente (${data.autoSynced.daysWritten} días).` + ); + } setLoading(false); }, []); diff --git a/src/lib/google-organic.ts b/src/lib/google-organic.ts index f8aa576..dc2d283 100644 --- a/src/lib/google-organic.ts +++ b/src/lib/google-organic.ts @@ -513,3 +513,149 @@ export async function syncOrganicTenant(opts: { warning: warnings[0], }; } + +type IntegRow = { + tenant_id: string; + provider: string; + external_account_id?: string; + meta?: { property_id?: string; measurement_id?: string; site_url?: string }; + last_sync_at?: string | null; +}; + +/** Sync all (or one) tenants with connected GA4/GSC. Rolling window ending yesterday. */ +export async function syncAllConnectedOrganic(opts?: { + tenantId?: string | null; + /** Days ending yesterday (default 28; daily cron can use 3–7). */ + days?: number; +}): Promise<{ + synced: number; + results: { tenantId: string; daysWritten: number; warning?: string }[]; +}> { + const headers = serviceHeaders(); + if (!headers) throw new Error('no service role'); + const days = Math.min(90, Math.max(3, opts?.days ?? 28)); + const tenantFilter = opts?.tenantId?.trim() || ''; + + const iRes = await fetch( + `${supabaseUrl()}/rest/v1/integrations?provider=in.(ga4,search_console)&status=eq.connected&select=tenant_id,provider,external_account_id,meta,last_sync_at`, + { headers, cache: 'no-store' } + ); + if (!iRes.ok) throw new Error((await iRes.text()).slice(0, 200)); + const integs = (await iRes.json()) as IntegRow[]; + + const byTenant = new Map< + string, + { ga4PropertyId?: string; gscSiteUrl?: string; measurementId?: string } + >(); + for (const i of integs) { + if (tenantFilter && i.tenant_id !== tenantFilter) continue; + const cur = byTenant.get(i.tenant_id) || {}; + if (i.provider === 'ga4') { + cur.ga4PropertyId = + i.meta?.property_id || + (i.external_account_id?.startsWith('properties/') + ? i.external_account_id + : undefined); + if (i.external_account_id?.startsWith('G-')) { + cur.measurementId = i.external_account_id; + } + cur.measurementId = cur.measurementId || i.meta?.measurement_id || undefined; + } + if (i.provider === 'search_console') { + cur.gscSiteUrl = i.meta?.site_url || i.external_account_id || undefined; + } + byTenant.set(i.tenant_id, cur); + } + + let ga4Catalog: Ga4Property[] | null = null; + const results: { + tenantId: string; + daysWritten: number; + warning?: string; + }[] = []; + + for (const [tenantId, cfg] of byTenant) { + let propId = cfg.ga4PropertyId; + if (!propId && cfg.measurementId?.startsWith('G-')) { + if (!ga4Catalog) { + try { + ga4Catalog = await listGa4Properties(); + } catch { + ga4Catalog = []; + } + } + const hit = ga4Catalog.find((p) => p.measurementId === cfg.measurementId); + propId = hit?.propertyId; + } + if (!propId && !cfg.gscSiteUrl) continue; + try { + const r = await syncOrganicTenant({ + tenantId, + ga4PropertyId: propId, + gscSiteUrl: cfg.gscSiteUrl, + days, + }); + // bump last_sync on integrations + await fetch( + `${supabaseUrl()}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(tenantId)}&provider=in.(ga4,search_console)`, + { + method: 'PATCH', + headers: { + ...headers, + 'Content-Type': 'application/json', + Prefer: 'return=minimal', + }, + body: JSON.stringify({ last_sync_at: new Date().toISOString() }), + } + ); + results.push({ tenantId, ...r }); + } catch (e) { + results.push({ + tenantId, + daysWritten: 0, + warning: e instanceof Error ? e.message : 'sync error', + }); + } + } + + return { + synced: results.filter((r) => r.daysWritten > 0).length, + results, + }; +} + +/** True if we should refresh this tenant (no yesterday row or sync older than maxAgeMs). */ +export async function organicNeedsRefresh( + tenantId: string, + maxAgeMs = 6 * 60 * 60 * 1000 +): Promise { + const headers = serviceHeaders(); + if (!headers) return false; + const yesterday = new Date(); + yesterday.setUTCDate(yesterday.getUTCDate() - 1); + const y = yesterday.toISOString().slice(0, 10); + + const [od, ig] = await Promise.all([ + fetch( + `${supabaseUrl()}/rest/v1/organic_daily?tenant_id=eq.${encodeURIComponent(tenantId)}&day=eq.${y}&select=day&limit=1`, + { headers, cache: 'no-store' } + ), + fetch( + `${supabaseUrl()}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(tenantId)}&provider=in.(ga4,search_console)&status=eq.connected&select=last_sync_at&limit=2`, + { headers, cache: 'no-store' } + ), + ]); + if (od.ok) { + const rows = await od.json(); + if (!rows.length) return true; + } + if (ig.ok) { + const rows = (await ig.json()) as { last_sync_at?: string }[]; + if (!rows.length) return false; // nothing connected + const latest = rows + .map((r) => (r.last_sync_at ? new Date(r.last_sync_at).getTime() : 0)) + .reduce((a, b) => Math.max(a, b), 0); + if (!latest || Date.now() - latest > maxAgeMs) return true; + } + return false; +}