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>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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<ReturnType<typeof listGa4Properties>> | 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,
|
||||
});
|
||||
}
|
||||
|
||||
56
src/app/api/cron/organic-sync/route.ts
Normal file
56
src/app/api/cron/organic-sync/route.ts
Normal file
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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{' '}
|
||||
<strong className="text-gray-800">marketing@4ventures.es</strong>{' '}
|
||||
(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).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -164,7 +170,7 @@ export default function GoogleOrganicPanel() {
|
||||
) : (
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
)}
|
||||
Sync 28d → Orgánico
|
||||
Actualizar ahora
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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);
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -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<boolean> {
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user