diff --git a/src/app/api/webhooks/clientify/route.ts b/src/app/api/webhooks/clientify/route.ts index fe6fb84..067ab4a 100644 --- a/src/app/api/webhooks/clientify/route.ts +++ b/src/app/api/webhooks/clientify/route.ts @@ -82,21 +82,65 @@ function mapDealStage(d: Json): string { function unwrapPayload(body: unknown): Json { if (!body || typeof body !== 'object') return {}; const b = body as Json; - // Common wrappers - for (const k of ['contact', 'deal', 'company', 'data', 'object', 'payload']) { + // Common wrappers (Clientify / Zapier / n8n) + for (const k of [ + 'contact', + 'deal', + 'company', + 'data', + 'object', + 'payload', + 'result', + ]) { const inner = b[k]; if (inner && typeof inner === 'object' && !Array.isArray(inner)) { - return inner as Json; + return { ...b, ...(inner as Json) }; } } return b; } -function guessEntity(q: string | null, payload: Json): string { +/** Normalize Clientify id aliases onto `id`. */ +function normalizeEntityIds(payload: Json): Json { + const out = { ...payload }; + if (out.id == null) { + const alt = + out.contact_id ?? + out.deal_id ?? + out.company_id ?? + out.pk ?? + out.object_id; + if (alt != null) out.id = alt; + } + // Sometimes only the API URL is present + if (out.id == null) { + const fromUrl = contactIdFromUrl(out.url || out.resource_uri || out.api_url); + if (fromUrl) out.id = fromUrl; + } + return out; +} + +function guessEntity(q: string | null, payload: Json, raw?: Json): string { if (q) return q.toLowerCase(); - if (payload.pipeline != null || payload.pipeline_stage != null || payload.amount != null) + const blob = `${str(payload.model, 40) || ''} ${str(raw?.event, 60) || ''} ${str(raw?.type, 40) || ''} ${str(raw?.entity, 40) || ''}`.toLowerCase(); + if (/\bdeal\b|oportunidad|pipeline/.test(blob)) return 'deal'; + if (/\bcontact\b|contacto/.test(blob)) return 'contact'; + if ( + payload.deal_id != null || + payload.pipeline != null || + payload.pipeline_stage != null || + payload.amount != null || + payload.amount_user != null + ) return 'deal'; - if (payload.first_name != null || payload.emails != null || payload.phones != null) + if ( + payload.contact_id != null || + payload.first_name != null || + payload.last_name != null || + payload.emails != null || + payload.phones != null || + payload.email != null + ) return 'contact'; if (payload.company_sector != null || (payload.name != null && payload.domain != null)) return 'company'; @@ -127,7 +171,7 @@ async function resolveTenantId(slugOrId: string | null): Promise async function upsertLead(tenantId: string, c: Json) { const headers = serviceHeaders(); if (!headers) throw new Error('no service role'); - const cid = str(c.id, 40); + const cid = str(c.id, 40) || str(c.contact_id, 40); if (!cid) throw new Error('contact missing id'); const id = uuid5(`clientify-lead:${tenantId}:${cid}`); const source = @@ -247,7 +291,7 @@ async function upsertLead(tenantId: string, c: Json) { async function upsertDeal(tenantId: string, d: Json) { const headers = serviceHeaders(); if (!headers) throw new Error('no service role'); - const did = str(d.id, 40); + const did = str(d.id, 40) || str(d.deal_id, 40); if (!did) throw new Error('deal missing id'); const id = uuid5(`clientify-deal:${tenantId}:${did}`); const stage = mapDealStage(d); @@ -370,10 +414,12 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: 'invalid_json' }, { status: 400 }); } - const payload = unwrapPayload(body); + const raw = (body && typeof body === 'object' ? body : {}) as Json; + const payload = normalizeEntityIds(unwrapPayload(body)); const entity = guessEntity( req.nextUrl.searchParams.get('entity'), - payload + payload, + raw ); try { diff --git a/src/lib/google-ads-auth.ts b/src/lib/google-ads-auth.ts index 784229c..dd51ec6 100644 --- a/src/lib/google-ads-auth.ts +++ b/src/lib/google-ads-auth.ts @@ -51,25 +51,14 @@ function oauthClientPair(): { clientId: string; clientSecret: string } | null { /** * Resolve a Google Ads API access token. - * Order: env GOOGLE_ADS_REFRESH → agency_oauth google_ads → agency_oauth google_drive (si tiene scope Ads). + * Prefer agency OAuth (fresh reconnect) over env refresh — stale env tokens + * often refresh OK but lack MCC access → PERMISSION_DENIED on customer queries. */ export async function resolveGoogleAdsAccessToken(): Promise { const attempts: TokenAttempt[] = []; const adsPair = adsClientPair(); const oauthPair = oauthClientPair(); - const envRefresh = - process.env.GOOGLE_ADS_REFRESH_TOKEN?.trim() || - process.env.GOOGLE_OAUTH_REFRESH_TOKEN?.trim(); - if (envRefresh && adsPair) { - attempts.push({ - refresh: envRefresh, - clientId: adsPair.clientId, - clientSecret: adsPair.clientSecret, - label: 'env', - }); - } - const headers = serviceHeaders(); if (headers) { try { @@ -83,31 +72,34 @@ export async function resolveGoogleAdsAccessToken(): Promise { refresh_token?: string | null; scopes?: string | null; }[]; + // google_drive+ads first (usually the live Agencia reconnect) for (const row of rows) { + if (row.provider !== 'google_drive') continue; const refresh = row.refresh_token?.trim(); if (!refresh) continue; - if (row.provider === 'google_ads' && adsPair) { - attempts.push({ - refresh, - clientId: adsPair.clientId, - clientSecret: adsPair.clientSecret, - label: 'agency_oauth.google_ads', - }); - } - if (row.provider === 'google_drive') { - const scopes = row.scopes || ''; - if (!scopes.includes('adwords') && !scopes.includes(ADS_SCOPE)) { - continue; - } - const pair = oauthPair || adsPair; - if (!pair) continue; - attempts.push({ - refresh, - clientId: pair.clientId, - clientSecret: pair.clientSecret, - label: 'agency_oauth.google_drive+ads', - }); + const scopes = row.scopes || ''; + if (!scopes.includes('adwords') && !scopes.includes(ADS_SCOPE)) { + continue; } + const pair = oauthPair || adsPair; + if (!pair) continue; + attempts.push({ + refresh, + clientId: pair.clientId, + clientSecret: pair.clientSecret, + label: 'agency_oauth.google_drive+ads', + }); + } + for (const row of rows) { + if (row.provider !== 'google_ads') continue; + const refresh = row.refresh_token?.trim(); + if (!refresh || !adsPair) continue; + attempts.push({ + refresh, + clientId: adsPair.clientId, + clientSecret: adsPair.clientSecret, + label: 'agency_oauth.google_ads', + }); } } } catch { @@ -115,6 +107,18 @@ export async function resolveGoogleAdsAccessToken(): Promise { } } + const envRefresh = + process.env.GOOGLE_ADS_REFRESH_TOKEN?.trim() || + process.env.GOOGLE_OAUTH_REFRESH_TOKEN?.trim(); + if (envRefresh && adsPair) { + attempts.push({ + refresh: envRefresh, + clientId: adsPair.clientId, + clientSecret: adsPair.clientSecret, + label: 'env', + }); + } + if (!attempts.length) { throw new Error( 'Faltan credenciales Google Ads (GOOGLE_ADS_REFRESH_TOKEN o reconectar Agencia → Drive con Ads)'