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:
@@ -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