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

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