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:
4Ventures
2026-09-24 12:10:02 +02:00
parent aded4c3514
commit d137b5796a
7 changed files with 267 additions and 88 deletions

View File

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

View 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);
}

View File

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