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', +]);