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:
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user