From 99895fe91860169ef0a12e21d9656660c28f5afd Mon Sep 17 00:00:00 2001
From: 4Ventures <4ventures@MacBook-Air-de-4Ventures.local>
Date: Thu, 24 Sep 2026 12:27:15 +0200
Subject: [PATCH] feat: conectar GA4/GSC/Drive desde la ficha (verificar + sync
real)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
---
src/app/api/admin/integrations/route.ts | 67 ++++-
src/components/ClientIntegrationsPanel.tsx | 45 +++-
src/lib/integration-activate.ts | 296 +++++++++++++++++++++
3 files changed, 387 insertions(+), 21 deletions(-)
create mode 100644 src/lib/integration-activate.ts
diff --git a/src/app/api/admin/integrations/route.ts b/src/app/api/admin/integrations/route.ts
index 4b3df22..fd3649c 100644
--- a/src/app/api/admin/integrations/route.ts
+++ b/src/app/api/admin/integrations/route.ts
@@ -7,6 +7,11 @@ import {
supabaseUrl,
userRole,
} from '@/lib/agency';
+import {
+ activateGoogleFromFicha,
+ LIVE_GOOGLE_PROVIDERS,
+ normalizeDriveFolderId,
+} from '@/lib/integration-activate';
export const dynamic = 'force-dynamic';
@@ -93,7 +98,7 @@ export async function GET(req: NextRequest) {
});
}
-/** PATCH — update one integration status/account (manual connect for now). */
+/** PATCH — update integration; GA4/GSC/Drive verify + sync via agency Google OAuth. */
export async function PATCH(req: NextRequest) {
const gate = await requireAgency();
if ('error' in gate && gate.error) return gate.error;
@@ -124,34 +129,69 @@ export async function PATCH(req: NextRequest) {
return NextResponse.json({ error: 'status inválido' }, { status: 400 });
}
+ const wantsLiveConnect =
+ LIVE_GOOGLE_PROVIDERS.has(provider) &&
+ (status === 'connected' ||
+ (status === 'pending' && Boolean(externalAccountId)));
+
+ if (wantsLiveConnect) {
+ let rawId = externalAccountId || '';
+ if (!rawId) {
+ const cur = await fetch(
+ `${supabaseUrl()}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(tenantId)}&provider=eq.${encodeURIComponent(provider)}&select=external_account_id`,
+ { headers, cache: 'no-store' }
+ );
+ if (cur.ok) {
+ const rows = (await cur.json()) as { external_account_id?: string }[];
+ rawId = rows[0]?.external_account_id || '';
+ }
+ }
+ if (!rawId) {
+ return NextResponse.json(
+ {
+ error: 'Indica ID o URL antes de conectar',
+ activation: { ok: false, message: 'Falta ID' },
+ },
+ { status: 400 }
+ );
+ }
+ const activation = await activateGoogleFromFicha({
+ tenantId,
+ provider: provider as 'ga4' | 'search_console' | 'google_drive',
+ rawId,
+ });
+ const list = await fetch(
+ `${supabaseUrl()}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(tenantId)}&provider=eq.${encodeURIComponent(provider)}&select=*`,
+ { headers, cache: 'no-store' }
+ );
+ const rows = list.ok ? await list.json() : [];
+ return NextResponse.json(
+ {
+ integration: rows[0] || null,
+ activation,
+ error: activation.ok ? undefined : activation.errorMessage || activation.message,
+ },
+ { status: activation.ok ? 200 : 422 }
+ );
+ }
+
const patch: Record = {
updated_at: new Date().toISOString(),
};
if (status) patch.status = status;
if (externalAccountId !== undefined) {
let id = externalAccountId || null;
- // Drive: accept full folder URL → store folder id only
if (provider === 'google_drive' && id) {
- const m = id.match(/\/folders\/([a-zA-Z0-9_-]+)/);
- if (m) id = m[1];
- else if (id.includes('http')) {
- const q = id.match(/[?&]id=([a-zA-Z0-9_-]+)/);
- if (q) id = q[1];
- }
+ id = normalizeDriveFolderId(id) || id;
}
patch.external_account_id = id;
}
if (label !== undefined) patch.label = label || null;
if (errorMessage !== undefined) patch.error_message = errorMessage || null;
if (enabled !== undefined) patch.enabled = enabled;
- // Manual «Conectar» only marks the reference — do not fake a data sync timestamp.
- // Real syncs (Ads discover, Orgánico, Superhuman…) set last_sync_at themselves.
if (status === 'connected') {
patch.error_message = null;
}
- if (status === 'disconnected') {
- // keep external id for re-link unless cleared
- }
if (!status && enabled === undefined && externalAccountId === undefined) {
return NextResponse.json(
@@ -173,7 +213,6 @@ export async function PATCH(req: NextRequest) {
});
if (!r.ok) {
- // upsert if missing
const ins = await fetch(`${base}/rest/v1/integrations`, {
method: 'POST',
headers: {
diff --git a/src/components/ClientIntegrationsPanel.tsx b/src/components/ClientIntegrationsPanel.tsx
index c773cf0..0dcaae1 100644
--- a/src/components/ClientIntegrationsPanel.tsx
+++ b/src/components/ClientIntegrationsPanel.tsx
@@ -27,7 +27,7 @@ const PLACEHOLDERS: Record = {
clientify: 'ID / workspace Clientify',
klaviyo: 'Account / list ID',
mailchimp: 'Audience ID',
- google_drive: 'Folder ID (auto-match o pegar)',
+ google_drive: 'URL de carpeta Drive o folder id',
wordpress: 'https://sitio.com + Application Password',
prestashop: 'https://tienda.com + API key',
shopify: 'mitienda.myshopify.com + Admin API token',
@@ -37,6 +37,8 @@ const PLACEHOLDERS: Record = {
linkedin: 'Company Page ID / linkedin.com/company/…',
};
+const LIVE_FROM_FICHA = new Set(['ga4', 'search_console', 'google_drive']);
+
export default function ClientIntegrationsPanel({
tenantId,
tenantName,
@@ -52,6 +54,7 @@ export default function ClientIntegrationsPanel({
const [integrations, setIntegrations] = useState([]);
const [providers, setProviders] = useState([]);
const [warning, setWarning] = useState(null);
+ const [flash, setFlash] = useState(null);
const [saving, setSaving] = useState(null);
const [accountDraft, setAccountDraft] = useState>({});
const [followed, setFollowed] = useState(true);
@@ -87,11 +90,18 @@ export default function ClientIntegrationsPanel({
async function patch(provider: string, body: Record) {
setSaving(provider);
- await fetch('/api/admin/integrations', {
+ setFlash(null);
+ const res = await fetch('/api/admin/integrations', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tenantId, provider, ...body }),
});
+ const data = await res.json().catch(() => ({}));
+ if (data.activation?.message) setFlash(data.activation.message);
+ else if (data.error) setFlash(String(data.error));
+ else if (LIVE_FROM_FICHA.has(provider) && body.status === 'connected') {
+ setFlash(res.ok ? 'Conexión verificada' : 'No se pudo activar');
+ }
await load();
onChanged?.();
setSaving(null);
@@ -168,6 +178,16 @@ export default function ClientIntegrationsPanel({
{warning}
)}
+ {flash && (
+
+ {flash}
+
+ )}
+
+ GA4, Search Console y Drive: al guardar/conectar se verifica con el
+ Google de agencia (marketing@) y se sincronizan datos. URL de carpeta =
+ mismo que folder id. Primero conecta Google en Agencia si aún no.
+
{groups.map((g) => {
const list = providers.filter((p) => p.group === g.id);
@@ -275,8 +295,11 @@ export default function ClientIntegrationsPanel({
disabled={saving === p.id || !enabled}
onClick={() =>
void patch(p.id, {
- status:
- status === 'disconnected' ? 'pending' : status,
+ status: LIVE_FROM_FICHA.has(p.id)
+ ? 'connected'
+ : status === 'disconnected'
+ ? 'pending'
+ : status,
externalAccountId: accountDraft[p.id] || '',
})
}
@@ -284,6 +307,8 @@ export default function ClientIntegrationsPanel({
>
{saving === p.id ? (
+ ) : LIVE_FROM_FICHA.has(p.id) ? (
+ 'Guardar y conectar'
) : (
'Guardar ID'
)}
@@ -291,9 +316,15 @@ export default function ClientIntegrationsPanel({
{p.id === 'google_drive' && (
- Preferible: Agencia → Conectar Drive → Re-emparejar
- (asigna carpeta por nombre). Aquí puedes pegar un Folder
- ID manual.
+ Pega la URL completa o solo el id (
+ …/folders/ID). Da igual: se normaliza y
+ se comprueba acceso con marketing@.
+
+ )}
+ {(p.id === 'ga4' || p.id === 'search_console') && (
+
+ Al conectar se verifica que marketing@ vea la propiedad /
+ site y se cargan datos en Orgánico.
)}
{(p.id === 'wordpress' ||
diff --git a/src/lib/integration-activate.ts b/src/lib/integration-activate.ts
new file mode 100644
index 0000000..e0144f3
--- /dev/null
+++ b/src/lib/integration-activate.ts
@@ -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;
+ status: 'connected' | 'error' | 'disconnected';
+ errorMessage?: string | null;
+ lastSyncAt?: string | null;
+ message?: string;
+ daysWritten?: number;
+};
+
+async function patchIntegration(
+ tenantId: string,
+ provider: string,
+ body: Record
+): Promise {
+ 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 {
+ 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',
+]);