feat: conectar GA4/GSC/Drive desde la ficha (verificar + sync real)

Al guardar ID o URL en el cliente se valida con OAuth de agencia;
Drive acepta URL o folder id; Orgánico se rellena si hay acceso.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
4Ventures
2026-09-24 12:27:15 +02:00
parent 81ba5df37d
commit 99895fe918
3 changed files with 387 additions and 21 deletions

View File

@@ -0,0 +1,296 @@
/**
* Activate a client-ficha Google integration for real:
* normalize ID → verify via agency OAuth → write meta → optional organic sync.
*/
import { serviceHeaders, supabaseUrl } from '@/lib/agency';
import {
getAgencyOAuthStatus,
getValidAccessToken,
} from '@/lib/google-drive';
import {
listGa4Properties,
listGscSites,
syncAllConnectedOrganic,
} from '@/lib/google-organic';
export function normalizeDriveFolderId(raw: string): string | null {
const s = raw.trim();
if (!s) return null;
const folder = s.match(/\/folders\/([a-zA-Z0-9_-]+)/);
if (folder) return folder[1];
const q = s.match(/[?&]id=([a-zA-Z0-9_-]+)/);
if (q) return q[1];
if (/^[a-zA-Z0-9_-]{10,}$/.test(s)) return s;
return null;
}
export async function fetchDriveFolderMeta(
folderId: string
): Promise<{ id: string; name: string }> {
const token = await getValidAccessToken();
const r = await fetch(
`https://www.googleapis.com/drive/v3/files/${encodeURIComponent(folderId)}?fields=id,name,mimeType,trashed&supportsAllDrives=true`,
{ headers: { Authorization: `Bearer ${token}` }, cache: 'no-store' }
);
if (!r.ok) {
throw new Error(
`Drive: no acceso a la carpeta (${(await r.text()).slice(0, 120)})`
);
}
const j = (await r.json()) as {
id?: string;
name?: string;
mimeType?: string;
trashed?: boolean;
};
if (j.trashed) throw new Error('Drive: la carpeta está en la papelera');
if (j.mimeType && j.mimeType !== 'application/vnd.google-apps.folder') {
throw new Error('Drive: el id no es una carpeta');
}
return { id: j.id || folderId, name: j.name || folderId };
}
export type ActivateResult = {
ok: boolean;
externalAccountId: string | null;
meta?: Record<string, unknown>;
status: 'connected' | 'error' | 'disconnected';
errorMessage?: string | null;
lastSyncAt?: string | null;
message?: string;
daysWritten?: number;
};
async function patchIntegration(
tenantId: string,
provider: string,
body: Record<string, unknown>
): Promise<void> {
const headers = serviceHeaders();
if (!headers) throw new Error('no service role');
const r = await fetch(
`${supabaseUrl()}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(tenantId)}&provider=eq.${encodeURIComponent(provider)}`,
{
method: 'PATCH',
headers: {
...headers,
'Content-Type': 'application/json',
Prefer: 'return=minimal',
},
body: JSON.stringify({ ...body, updated_at: new Date().toISOString() }),
}
);
if (!r.ok) throw new Error(`integrations patch: ${(await r.text()).slice(0, 200)}`);
}
/**
* Called when agency marks ga4 / search_console / google_drive as connected
* from the client ficha (ID or URL pasted).
*/
export async function activateGoogleFromFicha(opts: {
tenantId: string;
provider: 'ga4' | 'search_console' | 'google_drive';
rawId: string;
}): Promise<ActivateResult> {
const oauth = await getAgencyOAuthStatus();
if (!oauth.connected) {
return {
ok: false,
externalAccountId: opts.rawId || null,
status: 'error',
errorMessage:
'Primero Conectar Google en Agencia (panel 1) con marketing@.',
message: 'Falta OAuth de agencia',
};
}
try {
if (opts.provider === 'google_drive') {
const folderId = normalizeDriveFolderId(opts.rawId);
if (!folderId) {
return {
ok: false,
externalAccountId: opts.rawId || null,
status: 'error',
errorMessage: 'Pega la URL de la carpeta o el folder id.',
};
}
const folder = await fetchDriveFolderMeta(folderId);
const now = new Date().toISOString();
const meta = {
folder_name: folder.name,
source: 'ficha',
verified_at: now,
};
await patchIntegration(opts.tenantId, 'google_drive', {
status: 'connected',
external_account_id: folder.id,
meta,
last_sync_at: now,
error_message: null,
});
return {
ok: true,
externalAccountId: folder.id,
meta,
status: 'connected',
lastSyncAt: now,
message: `Drive OK · carpeta «${folder.name}»`,
};
}
if (opts.provider === 'ga4') {
const raw = opts.rawId.trim();
if (!raw) {
return {
ok: false,
externalAccountId: null,
status: 'error',
errorMessage: 'Indica G-… o properties/…',
};
}
const catalog = await listGa4Properties();
let hit = catalog.find(
(p) =>
p.measurementId === raw ||
p.propertyId === raw ||
p.propertyId === `properties/${raw}` ||
p.propertyId.replace(/^properties\//, '') === raw
);
if (!hit && raw.startsWith('G-')) {
return {
ok: false,
externalAccountId: raw,
status: 'error',
errorMessage: `GA4 ${raw} no visible para marketing@. Revisa acceso en Analytics.`,
};
}
if (!hit && raw.startsWith('properties/')) {
hit = {
propertyId: raw,
displayName: raw,
accountName: '',
measurementId: null,
};
}
if (!hit) {
return {
ok: false,
externalAccountId: raw,
status: 'error',
errorMessage: 'No encontré esa propiedad GA4 con la cuenta de agencia.',
};
}
const accountId = hit.measurementId || hit.propertyId;
const now = new Date().toISOString();
const meta = {
property_id: hit.propertyId,
measurement_id: hit.measurementId,
display_name: hit.displayName,
source: 'ficha',
verified_at: now,
};
await patchIntegration(opts.tenantId, 'ga4', {
status: 'connected',
external_account_id: accountId,
meta,
error_message: null,
});
const sync = await syncAllConnectedOrganic({
tenantId: opts.tenantId,
days: 28,
});
const days = sync.results[0]?.daysWritten ?? 0;
const warn = sync.results[0]?.warning;
return {
ok: true,
externalAccountId: accountId,
meta,
status: 'connected',
lastSyncAt: now,
daysWritten: days,
message: warn
? `GA4 enlazado · aviso: ${warn}`
: `GA4 OK · ${days} días en Orgánico`,
errorMessage: warn || null,
};
}
// search_console
const siteRaw = opts.rawId.trim().replace(/\/$/, '');
if (!siteRaw) {
return {
ok: false,
externalAccountId: null,
status: 'error',
errorMessage: 'Indica sc-domain:… o https://…',
};
}
const sites = await listGscSites();
const hit = sites.find(
(s) =>
s.siteUrl === siteRaw ||
s.siteUrl.replace(/\/$/, '') === siteRaw ||
s.siteUrl.includes(siteRaw.replace(/^sc-domain:/, ''))
);
if (!hit) {
return {
ok: false,
externalAccountId: siteRaw,
status: 'error',
errorMessage: `GSC «${siteRaw}» no visible para marketing@.`,
};
}
const now = new Date().toISOString();
const meta = {
site_url: hit.siteUrl,
source: 'ficha',
verified_at: now,
};
await patchIntegration(opts.tenantId, 'search_console', {
status: 'connected',
external_account_id: hit.siteUrl,
meta,
error_message: null,
});
const sync = await syncAllConnectedOrganic({
tenantId: opts.tenantId,
days: 28,
});
const days = sync.results[0]?.daysWritten ?? 0;
const warn = sync.results[0]?.warning;
return {
ok: true,
externalAccountId: hit.siteUrl,
meta,
status: 'connected',
lastSyncAt: now,
daysWritten: days,
message: warn
? `GSC enlazado · aviso: ${warn}`
: `GSC OK · ${days} días en Orgánico`,
errorMessage: warn || null,
};
} catch (e) {
const msg = e instanceof Error ? e.message : 'activate failed';
await patchIntegration(opts.tenantId, opts.provider, {
status: 'error',
error_message: msg.slice(0, 280),
external_account_id: opts.rawId || null,
}).catch(() => {});
return {
ok: false,
externalAccountId: opts.rawId || null,
status: 'error',
errorMessage: msg,
message: msg,
};
}
}
export const LIVE_GOOGLE_PROVIDERS = new Set([
'ga4',
'search_console',
'google_drive',
]);