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:
@@ -82,21 +82,65 @@ function mapDealStage(d: Json): string {
|
|||||||
function unwrapPayload(body: unknown): Json {
|
function unwrapPayload(body: unknown): Json {
|
||||||
if (!body || typeof body !== 'object') return {};
|
if (!body || typeof body !== 'object') return {};
|
||||||
const b = body as Json;
|
const b = body as Json;
|
||||||
// Common wrappers
|
// Common wrappers (Clientify / Zapier / n8n)
|
||||||
for (const k of ['contact', 'deal', 'company', 'data', 'object', 'payload']) {
|
for (const k of [
|
||||||
|
'contact',
|
||||||
|
'deal',
|
||||||
|
'company',
|
||||||
|
'data',
|
||||||
|
'object',
|
||||||
|
'payload',
|
||||||
|
'result',
|
||||||
|
]) {
|
||||||
const inner = b[k];
|
const inner = b[k];
|
||||||
if (inner && typeof inner === 'object' && !Array.isArray(inner)) {
|
if (inner && typeof inner === 'object' && !Array.isArray(inner)) {
|
||||||
return inner as Json;
|
return { ...b, ...(inner as Json) };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return b;
|
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 (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';
|
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';
|
return 'contact';
|
||||||
if (payload.company_sector != null || (payload.name != null && payload.domain != null))
|
if (payload.company_sector != null || (payload.name != null && payload.domain != null))
|
||||||
return 'company';
|
return 'company';
|
||||||
@@ -127,7 +171,7 @@ async function resolveTenantId(slugOrId: string | null): Promise<string | null>
|
|||||||
async function upsertLead(tenantId: string, c: Json) {
|
async function upsertLead(tenantId: string, c: Json) {
|
||||||
const headers = serviceHeaders();
|
const headers = serviceHeaders();
|
||||||
if (!headers) throw new Error('no service role');
|
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');
|
if (!cid) throw new Error('contact missing id');
|
||||||
const id = uuid5(`clientify-lead:${tenantId}:${cid}`);
|
const id = uuid5(`clientify-lead:${tenantId}:${cid}`);
|
||||||
const source =
|
const source =
|
||||||
@@ -247,7 +291,7 @@ async function upsertLead(tenantId: string, c: Json) {
|
|||||||
async function upsertDeal(tenantId: string, d: Json) {
|
async function upsertDeal(tenantId: string, d: Json) {
|
||||||
const headers = serviceHeaders();
|
const headers = serviceHeaders();
|
||||||
if (!headers) throw new Error('no service role');
|
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');
|
if (!did) throw new Error('deal missing id');
|
||||||
const id = uuid5(`clientify-deal:${tenantId}:${did}`);
|
const id = uuid5(`clientify-deal:${tenantId}:${did}`);
|
||||||
const stage = mapDealStage(d);
|
const stage = mapDealStage(d);
|
||||||
@@ -370,10 +414,12 @@ export async function POST(req: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'invalid_json' }, { status: 400 });
|
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(
|
const entity = guessEntity(
|
||||||
req.nextUrl.searchParams.get('entity'),
|
req.nextUrl.searchParams.get('entity'),
|
||||||
payload
|
payload,
|
||||||
|
raw
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -51,25 +51,14 @@ function oauthClientPair(): { clientId: string; clientSecret: string } | null {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve a Google Ads API access token.
|
* 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> {
|
export async function resolveGoogleAdsAccessToken(): Promise<string> {
|
||||||
const attempts: TokenAttempt[] = [];
|
const attempts: TokenAttempt[] = [];
|
||||||
const adsPair = adsClientPair();
|
const adsPair = adsClientPair();
|
||||||
const oauthPair = oauthClientPair();
|
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();
|
const headers = serviceHeaders();
|
||||||
if (headers) {
|
if (headers) {
|
||||||
try {
|
try {
|
||||||
@@ -83,31 +72,34 @@ export async function resolveGoogleAdsAccessToken(): Promise<string> {
|
|||||||
refresh_token?: string | null;
|
refresh_token?: string | null;
|
||||||
scopes?: string | null;
|
scopes?: string | null;
|
||||||
}[];
|
}[];
|
||||||
|
// google_drive+ads first (usually the live Agencia reconnect)
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
|
if (row.provider !== 'google_drive') continue;
|
||||||
const refresh = row.refresh_token?.trim();
|
const refresh = row.refresh_token?.trim();
|
||||||
if (!refresh) continue;
|
if (!refresh) continue;
|
||||||
if (row.provider === 'google_ads' && adsPair) {
|
const scopes = row.scopes || '';
|
||||||
attempts.push({
|
if (!scopes.includes('adwords') && !scopes.includes(ADS_SCOPE)) {
|
||||||
refresh,
|
continue;
|
||||||
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 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 {
|
} 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) {
|
if (!attempts.length) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'Faltan credenciales Google Ads (GOOGLE_ADS_REFRESH_TOKEN o reconectar Agencia → Drive con Ads)'
|
'Faltan credenciales Google Ads (GOOGLE_ADS_REFRESH_TOKEN o reconectar Agencia → Drive con Ads)'
|
||||||
|
|||||||
Reference in New Issue
Block a user