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:
296
src/lib/integration-activate.ts
Normal file
296
src/lib/integration-activate.ts
Normal 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',
|
||||
]);
|
||||
Reference in New Issue
Block a user