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;
+}