Comparativas WoW/MoM/YoY muestran prev/Δ además del %; sync ads admite histórico desde 2025; tabla ads↔CRM añade cierres, ventas y ROAS. Co-authored-by: Cursor <cursoragent@cursor.com>
758 lines
22 KiB
TypeScript
758 lines
22 KiB
TypeScript
/** GA4 + Search Console discover & sync using agency Google OAuth (marketing@). */
|
||
|
||
import { serviceHeaders, supabaseUrl } from '@/lib/agency';
|
||
import { getValidAccessToken } from '@/lib/google-drive';
|
||
|
||
export type Ga4Property = {
|
||
propertyId: string; // properties/123
|
||
displayName: string;
|
||
accountName: string;
|
||
measurementId: string | null; // G-XXXX
|
||
};
|
||
|
||
export type GscSite = {
|
||
siteUrl: string;
|
||
permissionLevel: string;
|
||
};
|
||
|
||
function n(v: unknown): number {
|
||
const x = Number(v);
|
||
return Number.isFinite(x) ? x : 0;
|
||
}
|
||
|
||
export async function listGa4Properties(): Promise<Ga4Property[]> {
|
||
const token = await getValidAccessToken();
|
||
const out: Ga4Property[] = [];
|
||
let pageToken: string | undefined;
|
||
|
||
do {
|
||
const url = new URL(
|
||
'https://analyticsadmin.googleapis.com/v1beta/accountSummaries'
|
||
);
|
||
url.searchParams.set('pageSize', '200');
|
||
if (pageToken) url.searchParams.set('pageToken', pageToken);
|
||
|
||
const r = await fetch(url.toString(), {
|
||
headers: { Authorization: `Bearer ${token}` },
|
||
cache: 'no-store',
|
||
});
|
||
if (!r.ok) {
|
||
throw new Error(`GA4 Admin: ${(await r.text()).slice(0, 300)}`);
|
||
}
|
||
const j = (await r.json()) as {
|
||
accountSummaries?: {
|
||
displayName?: string;
|
||
propertySummaries?: { property?: string; displayName?: string }[];
|
||
}[];
|
||
nextPageToken?: string;
|
||
};
|
||
|
||
for (const acc of j.accountSummaries || []) {
|
||
for (const p of acc.propertySummaries || []) {
|
||
if (!p.property) continue;
|
||
const measurementId = await fetchMeasurementId(token, p.property);
|
||
out.push({
|
||
propertyId: p.property,
|
||
displayName: p.displayName || p.property,
|
||
accountName: acc.displayName || '',
|
||
measurementId,
|
||
});
|
||
}
|
||
}
|
||
pageToken = j.nextPageToken;
|
||
} while (pageToken);
|
||
|
||
return out;
|
||
}
|
||
|
||
async function fetchMeasurementId(
|
||
token: string,
|
||
propertyResource: string
|
||
): Promise<string | null> {
|
||
// propertyResource = "properties/123456"
|
||
const r = await fetch(
|
||
`https://analyticsadmin.googleapis.com/v1beta/${propertyResource}/dataStreams`,
|
||
{
|
||
headers: { Authorization: `Bearer ${token}` },
|
||
cache: 'no-store',
|
||
}
|
||
);
|
||
if (!r.ok) return null;
|
||
const j = (await r.json()) as {
|
||
dataStreams?: {
|
||
webStreamData?: { measurementId?: string };
|
||
type?: string;
|
||
}[];
|
||
};
|
||
for (const s of j.dataStreams || []) {
|
||
const mid = s.webStreamData?.measurementId;
|
||
if (mid) return mid;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
export async function listGscSites(): Promise<GscSite[]> {
|
||
const token = await getValidAccessToken();
|
||
const r = await fetch('https://www.googleapis.com/webmasters/v3/sites', {
|
||
headers: { Authorization: `Bearer ${token}` },
|
||
cache: 'no-store',
|
||
});
|
||
if (!r.ok) {
|
||
throw new Error(`GSC: ${(await r.text()).slice(0, 300)}`);
|
||
}
|
||
const j = (await r.json()) as {
|
||
siteEntry?: { siteUrl?: string; permissionLevel?: string }[];
|
||
};
|
||
return (j.siteEntry || [])
|
||
.filter((s) => s.siteUrl)
|
||
.map((s) => ({
|
||
siteUrl: s.siteUrl!,
|
||
permissionLevel: s.permissionLevel || '',
|
||
}));
|
||
}
|
||
|
||
function normName(s: string): string {
|
||
return s
|
||
.toLowerCase()
|
||
.normalize('NFD')
|
||
.replace(/[\u0300-\u036f]/g, '')
|
||
.replace(/[^a-z0-9]+/g, ' ')
|
||
.trim();
|
||
}
|
||
|
||
function scoreNames(a: string, b: string): number {
|
||
const x = normName(a);
|
||
const y = normName(b);
|
||
if (!x || !y) return 0;
|
||
if (x === y) return 100;
|
||
if (x.includes(y) || y.includes(x)) return 80;
|
||
const xt = new Set(x.split(' ').filter(Boolean));
|
||
const yt = new Set(y.split(' ').filter(Boolean));
|
||
let hit = 0;
|
||
for (const t of xt) if (yt.has(t)) hit += 1;
|
||
if (!xt.size || !yt.size) return 0;
|
||
return Math.round((100 * hit) / Math.max(xt.size, yt.size));
|
||
}
|
||
|
||
export type OrganicMatch = {
|
||
tenantId: string;
|
||
tenantName: string;
|
||
tenantSlug: string;
|
||
ga4?: {
|
||
propertyId: string;
|
||
measurementId: string | null;
|
||
displayName: string;
|
||
score: number;
|
||
} | null;
|
||
gsc?: { siteUrl: string; score: number } | null;
|
||
};
|
||
|
||
export async function matchOrganicToTenants(
|
||
tenants: { id: string; name: string; slug: string }[],
|
||
ga4: Ga4Property[],
|
||
gsc: GscSite[]
|
||
): Promise<OrganicMatch[]> {
|
||
return tenants.map((t) => {
|
||
let bestGa: OrganicMatch['ga4'] = null;
|
||
for (const p of ga4) {
|
||
const s = Math.max(
|
||
scoreNames(t.name, p.displayName),
|
||
scoreNames(t.slug, p.displayName),
|
||
scoreNames(t.name, p.accountName)
|
||
);
|
||
if (!bestGa || s > bestGa.score) {
|
||
bestGa = {
|
||
propertyId: p.propertyId,
|
||
measurementId: p.measurementId,
|
||
displayName: p.displayName,
|
||
score: s,
|
||
};
|
||
}
|
||
}
|
||
if (bestGa && bestGa.score < 50) bestGa = null;
|
||
|
||
let bestGsc: OrganicMatch['gsc'] = null;
|
||
for (const s of gsc) {
|
||
const url = s.siteUrl.replace(/^sc-domain:/, '').replace(/\/$/, '');
|
||
const score = Math.max(
|
||
scoreNames(t.name, url),
|
||
scoreNames(t.slug, url),
|
||
scoreNames(t.name, s.siteUrl)
|
||
);
|
||
if (!bestGsc || score > bestGsc.score) {
|
||
bestGsc = { siteUrl: s.siteUrl, score };
|
||
}
|
||
}
|
||
if (bestGsc && bestGsc.score < 40) bestGsc = null;
|
||
|
||
return {
|
||
tenantId: t.id,
|
||
tenantName: t.name,
|
||
tenantSlug: t.slug,
|
||
ga4: bestGa,
|
||
gsc: bestGsc,
|
||
};
|
||
});
|
||
}
|
||
|
||
/** Apply matches → integrations ga4 / search_console connected. */
|
||
export async function applyOrganicMatches(
|
||
matches: OrganicMatch[]
|
||
): Promise<{ ga4: number; gsc: number }> {
|
||
const headers = serviceHeaders();
|
||
if (!headers) throw new Error('no service role');
|
||
let ga4 = 0;
|
||
let gsc = 0;
|
||
const base = supabaseUrl();
|
||
|
||
for (const m of matches) {
|
||
if (m.ga4) {
|
||
const accountId = m.ga4.measurementId || m.ga4.propertyId;
|
||
const ok = await upsertIntegration(base, headers, m.tenantId, 'ga4', {
|
||
status: 'connected',
|
||
external_account_id: accountId,
|
||
label: m.ga4.displayName,
|
||
meta: {
|
||
property_id: m.ga4.propertyId,
|
||
measurement_id: m.ga4.measurementId,
|
||
matched_at: new Date().toISOString(),
|
||
},
|
||
// last_sync_at solo tras volcar organic_daily (no al emparejar)
|
||
error_message: null,
|
||
});
|
||
if (ok) ga4 += 1;
|
||
}
|
||
if (m.gsc) {
|
||
const ok = await upsertIntegration(
|
||
base,
|
||
headers,
|
||
m.tenantId,
|
||
'search_console',
|
||
{
|
||
status: 'connected',
|
||
external_account_id: m.gsc.siteUrl,
|
||
label: m.gsc.siteUrl,
|
||
meta: {
|
||
site_url: m.gsc.siteUrl,
|
||
matched_at: new Date().toISOString(),
|
||
},
|
||
error_message: null,
|
||
}
|
||
);
|
||
if (ok) gsc += 1;
|
||
}
|
||
}
|
||
return { ga4, gsc };
|
||
}
|
||
|
||
async function upsertIntegration(
|
||
base: string,
|
||
headers: { apikey: string; Authorization: string },
|
||
tenantId: string,
|
||
provider: string,
|
||
patch: Record<string, unknown>
|
||
): Promise<boolean> {
|
||
const r = await fetch(
|
||
`${base}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(tenantId)}&provider=eq.${provider}`,
|
||
{
|
||
method: 'PATCH',
|
||
headers: {
|
||
...headers,
|
||
'Content-Type': 'application/json',
|
||
Prefer: 'return=minimal',
|
||
},
|
||
body: JSON.stringify(patch),
|
||
}
|
||
);
|
||
if (r.ok) return true;
|
||
const ins = await fetch(`${base}/rest/v1/integrations`, {
|
||
method: 'POST',
|
||
headers: {
|
||
...headers,
|
||
'Content-Type': 'application/json',
|
||
Prefer: 'resolution=merge-duplicates',
|
||
},
|
||
body: JSON.stringify({
|
||
tenant_id: tenantId,
|
||
provider,
|
||
enabled: true,
|
||
...patch,
|
||
}),
|
||
});
|
||
return ins.ok;
|
||
}
|
||
|
||
function isoDay(d: Date): string {
|
||
return d.toISOString().slice(0, 10);
|
||
}
|
||
|
||
export async function fetchGscTopRows(opts: {
|
||
siteUrl: string;
|
||
startDate: string;
|
||
endDate: string;
|
||
dimension: 'query' | 'page';
|
||
rowLimit?: number;
|
||
}): Promise<
|
||
{ key: string; clicks: number; impressions: number; ctr: number; position: number }[]
|
||
> {
|
||
const token = await getValidAccessToken();
|
||
const site = encodeURIComponent(opts.siteUrl);
|
||
const r = await fetch(
|
||
`https://www.googleapis.com/webmasters/v3/sites/${site}/searchAnalytics/query`,
|
||
{
|
||
method: 'POST',
|
||
headers: {
|
||
Authorization: `Bearer ${token}`,
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify({
|
||
startDate: opts.startDate,
|
||
endDate: opts.endDate,
|
||
dimensions: [opts.dimension],
|
||
rowLimit: opts.rowLimit ?? 25,
|
||
}),
|
||
}
|
||
);
|
||
if (!r.ok) throw new Error(`GSC ${opts.dimension}: ${(await r.text()).slice(0, 160)}`);
|
||
const j = (await r.json()) as {
|
||
rows?: {
|
||
keys?: string[];
|
||
clicks?: number;
|
||
impressions?: number;
|
||
ctr?: number;
|
||
position?: number;
|
||
}[];
|
||
};
|
||
return (j.rows || []).map((row) => ({
|
||
key: row.keys?.[0] || '',
|
||
clicks: n(row.clicks),
|
||
impressions: n(row.impressions),
|
||
ctr: row.ctr != null ? Math.round(n(row.ctr) * 10000) / 100 : 0,
|
||
position: row.position != null ? Math.round(n(row.position) * 10) / 10 : 0,
|
||
}));
|
||
}
|
||
|
||
export async function fetchGa4TopLandings(opts: {
|
||
propertyId: string;
|
||
startDate: string;
|
||
endDate: string;
|
||
rowLimit?: number;
|
||
}): Promise<{ page: string; sessions: number; users: number }[]> {
|
||
const token = await getValidAccessToken();
|
||
const prop = opts.propertyId.replace(/^properties\//, '');
|
||
const r = await fetch(
|
||
`https://analyticsdata.googleapis.com/v1beta/properties/${prop}:runReport`,
|
||
{
|
||
method: 'POST',
|
||
headers: {
|
||
Authorization: `Bearer ${token}`,
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify({
|
||
dateRanges: [{ startDate: opts.startDate, endDate: opts.endDate }],
|
||
dimensions: [{ name: 'landingPagePlusQueryString' }],
|
||
metrics: [{ name: 'sessions' }, { name: 'totalUsers' }],
|
||
dimensionFilter: {
|
||
filter: {
|
||
fieldName: 'sessionDefaultChannelGroup',
|
||
stringFilter: { value: 'Organic Search' },
|
||
},
|
||
},
|
||
orderBys: [{ metric: { metricName: 'sessions' }, desc: true }],
|
||
limit: opts.rowLimit ?? 20,
|
||
}),
|
||
}
|
||
);
|
||
if (!r.ok) throw new Error(`GA4 landings: ${(await r.text()).slice(0, 160)}`);
|
||
const j = (await r.json()) as {
|
||
rows?: {
|
||
dimensionValues?: { value?: string }[];
|
||
metricValues?: { value?: string }[];
|
||
}[];
|
||
};
|
||
return (j.rows || []).map((row) => ({
|
||
page: row.dimensionValues?.[0]?.value || '/',
|
||
sessions: n(row.metricValues?.[0]?.value),
|
||
users: n(row.metricValues?.[1]?.value),
|
||
}));
|
||
}
|
||
|
||
/** Sync last N days of GSC + GA4 into organic_daily for one tenant. */
|
||
export async function syncOrganicTenant(opts: {
|
||
tenantId: string;
|
||
ga4PropertyId?: string | null; // properties/123
|
||
gscSiteUrl?: string | null;
|
||
days?: number;
|
||
}): Promise<{ daysWritten: number; warning?: string }> {
|
||
const headers = serviceHeaders();
|
||
if (!headers) throw new Error('no service role');
|
||
const days = opts.days ?? 28;
|
||
const token = await getValidAccessToken();
|
||
|
||
const until = new Date();
|
||
until.setUTCDate(until.getUTCDate() - 1); // yesterday
|
||
const since = new Date(until);
|
||
since.setUTCDate(since.getUTCDate() - (days - 1));
|
||
const start = isoDay(since);
|
||
const end = isoDay(until);
|
||
|
||
const byDay = new Map<
|
||
string,
|
||
{
|
||
gsc_clicks: number;
|
||
gsc_impressions: number;
|
||
gsc_ctr: number | null;
|
||
gsc_avg_position: number | null;
|
||
ga4_sessions: number;
|
||
ga4_users: number;
|
||
ga4_organic_sessions: number;
|
||
ga4_conversions: number;
|
||
}
|
||
>();
|
||
|
||
function ensure(day: string) {
|
||
if (!byDay.has(day)) {
|
||
byDay.set(day, {
|
||
gsc_clicks: 0,
|
||
gsc_impressions: 0,
|
||
gsc_ctr: null,
|
||
gsc_avg_position: null,
|
||
ga4_sessions: 0,
|
||
ga4_users: 0,
|
||
ga4_organic_sessions: 0,
|
||
ga4_conversions: 0,
|
||
});
|
||
}
|
||
return byDay.get(day)!;
|
||
}
|
||
|
||
const warnings: string[] = [];
|
||
|
||
if (opts.gscSiteUrl) {
|
||
try {
|
||
const site = encodeURIComponent(opts.gscSiteUrl);
|
||
const r = await fetch(
|
||
`https://www.googleapis.com/webmasters/v3/sites/${site}/searchAnalytics/query`,
|
||
{
|
||
method: 'POST',
|
||
headers: {
|
||
Authorization: `Bearer ${token}`,
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify({
|
||
startDate: start,
|
||
endDate: end,
|
||
dimensions: ['date'],
|
||
rowLimit: 1000,
|
||
}),
|
||
}
|
||
);
|
||
if (!r.ok) {
|
||
warnings.push(`GSC ${(await r.text()).slice(0, 120)}`);
|
||
} else {
|
||
const j = (await r.json()) as {
|
||
rows?: {
|
||
keys?: string[];
|
||
clicks?: number;
|
||
impressions?: number;
|
||
ctr?: number;
|
||
position?: number;
|
||
}[];
|
||
};
|
||
for (const row of j.rows || []) {
|
||
const day = (row.keys?.[0] || '').slice(0, 10);
|
||
if (!day) continue;
|
||
const cur = ensure(day);
|
||
cur.gsc_clicks = n(row.clicks);
|
||
cur.gsc_impressions = n(row.impressions);
|
||
cur.gsc_ctr =
|
||
row.ctr != null ? Math.round(n(row.ctr) * 10000) / 100 : null;
|
||
cur.gsc_avg_position =
|
||
row.position != null ? Math.round(n(row.position) * 10) / 10 : null;
|
||
}
|
||
}
|
||
} catch (e) {
|
||
warnings.push(e instanceof Error ? e.message : 'gsc error');
|
||
}
|
||
}
|
||
|
||
if (opts.ga4PropertyId) {
|
||
try {
|
||
const prop = opts.ga4PropertyId.replace(/^properties\//, '');
|
||
// Totals by day
|
||
const r = await fetch(
|
||
`https://analyticsdata.googleapis.com/v1beta/properties/${prop}:runReport`,
|
||
{
|
||
method: 'POST',
|
||
headers: {
|
||
Authorization: `Bearer ${token}`,
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify({
|
||
dateRanges: [{ startDate: start, endDate: end }],
|
||
dimensions: [{ name: 'date' }],
|
||
metrics: [
|
||
{ name: 'sessions' },
|
||
{ name: 'totalUsers' },
|
||
{ name: 'conversions' },
|
||
],
|
||
}),
|
||
}
|
||
);
|
||
if (!r.ok) {
|
||
warnings.push(`GA4 ${(await r.text()).slice(0, 120)}`);
|
||
} else {
|
||
const j = (await r.json()) as {
|
||
rows?: {
|
||
dimensionValues?: { value?: string }[];
|
||
metricValues?: { value?: string }[];
|
||
}[];
|
||
};
|
||
for (const row of j.rows || []) {
|
||
const raw = row.dimensionValues?.[0]?.value || '';
|
||
// GA4 date = YYYYMMDD
|
||
const day =
|
||
raw.length === 8
|
||
? `${raw.slice(0, 4)}-${raw.slice(4, 6)}-${raw.slice(6, 8)}`
|
||
: raw.slice(0, 10);
|
||
if (!day) continue;
|
||
const cur = ensure(day);
|
||
cur.ga4_sessions = n(row.metricValues?.[0]?.value);
|
||
cur.ga4_users = n(row.metricValues?.[1]?.value);
|
||
cur.ga4_conversions = n(row.metricValues?.[2]?.value);
|
||
}
|
||
}
|
||
|
||
// Organic sessions by day
|
||
const r2 = await fetch(
|
||
`https://analyticsdata.googleapis.com/v1beta/properties/${prop}:runReport`,
|
||
{
|
||
method: 'POST',
|
||
headers: {
|
||
Authorization: `Bearer ${token}`,
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify({
|
||
dateRanges: [{ startDate: start, endDate: end }],
|
||
dimensions: [{ name: 'date' }, { name: 'sessionDefaultChannelGroup' }],
|
||
metrics: [{ name: 'sessions' }],
|
||
dimensionFilter: {
|
||
filter: {
|
||
fieldName: 'sessionDefaultChannelGroup',
|
||
stringFilter: { value: 'Organic Search' },
|
||
},
|
||
},
|
||
}),
|
||
}
|
||
);
|
||
if (r2.ok) {
|
||
const j = (await r2.json()) as {
|
||
rows?: {
|
||
dimensionValues?: { value?: string }[];
|
||
metricValues?: { value?: string }[];
|
||
}[];
|
||
};
|
||
for (const row of j.rows || []) {
|
||
const raw = row.dimensionValues?.[0]?.value || '';
|
||
const day =
|
||
raw.length === 8
|
||
? `${raw.slice(0, 4)}-${raw.slice(4, 6)}-${raw.slice(6, 8)}`
|
||
: raw.slice(0, 10);
|
||
if (!day) continue;
|
||
ensure(day).ga4_organic_sessions = n(row.metricValues?.[0]?.value);
|
||
}
|
||
}
|
||
} catch (e) {
|
||
warnings.push(e instanceof Error ? e.message : 'ga4 error');
|
||
}
|
||
}
|
||
|
||
const rows = [...byDay.entries()].map(([day, v]) => ({
|
||
tenant_id: opts.tenantId,
|
||
day,
|
||
...v,
|
||
source: 'sync',
|
||
}));
|
||
|
||
if (!rows.length) {
|
||
return {
|
||
daysWritten: 0,
|
||
warning: warnings[0] || 'Sin filas GA4/GSC en el periodo',
|
||
};
|
||
}
|
||
|
||
const upsert = await fetch(
|
||
`${supabaseUrl()}/rest/v1/organic_daily?on_conflict=tenant_id,day`,
|
||
{
|
||
method: 'POST',
|
||
headers: {
|
||
...headers,
|
||
'Content-Type': 'application/json',
|
||
Prefer: 'resolution=merge-duplicates,return=minimal',
|
||
},
|
||
body: JSON.stringify(rows),
|
||
}
|
||
);
|
||
if (!upsert.ok) {
|
||
const msg = await upsert.text();
|
||
throw new Error(
|
||
msg.includes('organic_daily')
|
||
? 'Aplicar sql/organic_geo.sql'
|
||
: msg.slice(0, 300)
|
||
);
|
||
}
|
||
|
||
return {
|
||
daysWritten: rows.length,
|
||
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,
|
||
});
|
||
// Solo marcar last_sync_at si hubo filas (no mentir tras match vacío)
|
||
if (r.daysWritten > 0) {
|
||
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;
|
||
}
|