fix: Google Ads via marketing@ OAuth first; harden Clientify webhooks

Prefer agency google_drive+ads token over stale env refresh to avoid
PERMISSION_DENIED, and accept Clientify contact_id/deal_id payload aliases.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
4Ventures
2026-10-05 12:25:52 +02:00
parent ec7f1f05de
commit a6509bb89c
2 changed files with 94 additions and 44 deletions

View File

@@ -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<string | null>
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 {

View File

@@ -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<string> {
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<string> {
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<string> {
}
}
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)'