fix: creatividades — Google siempre con fallback; Meta preview 9:16 y textos reales
OAuth Ads caducado: aviso amable + campañas warehouse. Meta deja de volcar todas las variantes del asset_feed. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -4,6 +4,10 @@ import { googleAdsConfigured } from '@/lib/ads-discovery';
|
||||
import { serviceHeaders, supabaseUrl } from '@/lib/agency';
|
||||
import type { AdCreativeCard, CreativeAsset } from '@/lib/ad-creatives-meta';
|
||||
import { aggregateDailyByAd } from '@/lib/ad-creatives-meta';
|
||||
import {
|
||||
friendlyGoogleCreativeWarning,
|
||||
resolveGoogleAdsAccessToken,
|
||||
} from '@/lib/google-ads-auth';
|
||||
|
||||
const GADS_API = 'https://googleads.googleapis.com/v22';
|
||||
|
||||
@@ -77,31 +81,7 @@ function uniq(arr: string[]): string[] {
|
||||
}
|
||||
|
||||
async function googleAccessToken(): Promise<string> {
|
||||
const refresh =
|
||||
process.env.GOOGLE_ADS_REFRESH_TOKEN?.trim() ||
|
||||
process.env.GOOGLE_OAUTH_REFRESH_TOKEN?.trim();
|
||||
const clientId =
|
||||
process.env.GOOGLE_ADS_CLIENT_ID?.trim() ||
|
||||
process.env.GOOGLE_OAUTH_CLIENT_ID?.trim();
|
||||
const clientSecret =
|
||||
process.env.GOOGLE_ADS_CLIENT_SECRET?.trim() ||
|
||||
process.env.GOOGLE_OAUTH_CLIENT_SECRET?.trim();
|
||||
if (!refresh || !clientId || !clientSecret) {
|
||||
throw new Error('Faltan credenciales Google Ads');
|
||||
}
|
||||
const body = new URLSearchParams({
|
||||
refresh_token: refresh,
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
grant_type: 'refresh_token',
|
||||
});
|
||||
const r = await fetch('https://oauth2.googleapis.com/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body,
|
||||
});
|
||||
if (!r.ok) throw new Error(`Google token: ${(await r.text()).slice(0, 180)}`);
|
||||
return ((await r.json()) as { access_token: string }).access_token;
|
||||
return resolveGoogleAdsAccessToken();
|
||||
}
|
||||
|
||||
function gadsHeaders(
|
||||
@@ -205,9 +185,40 @@ type AgBucket = {
|
||||
assets: CreativeAsset[];
|
||||
};
|
||||
|
||||
function cardsFromDaily(
|
||||
dailyRows: Parameters<typeof aggregateDailyByAd>[0],
|
||||
since: string,
|
||||
until: string,
|
||||
limit: number,
|
||||
description: string
|
||||
): AdCreativeCard[] {
|
||||
return aggregateDailyByAd(dailyRows, 'google', since, until)
|
||||
.slice(0, limit)
|
||||
.map((agg) => ({
|
||||
platform: 'google' as const,
|
||||
adId: agg.adId,
|
||||
adName: agg.adName || agg.campaignName || agg.adId,
|
||||
campaignName: agg.campaignName,
|
||||
spend: Math.round(agg.spend * 100) / 100,
|
||||
impressions: agg.impressions,
|
||||
clicks: agg.clicks,
|
||||
conversions: Math.round(agg.conversions * 100) / 100,
|
||||
cpl: agg.conversions > 0 ? agg.spend / agg.conversions : null,
|
||||
mediaType: 'campaign',
|
||||
thumbnailUrl: '',
|
||||
imageUrl: '',
|
||||
headline: agg.campaignName || agg.adName,
|
||||
primaryText: description,
|
||||
headlines: [agg.campaignName || agg.adName].filter(Boolean),
|
||||
descriptions: [description],
|
||||
assets: [] as CreativeAsset[],
|
||||
status: undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Live Google creatives: Performance Max asset groups + RSA/RDA ads.
|
||||
* Returns full asset packs (images, videos, logos, texts) for zoom UI.
|
||||
* Always falls back to ad_daily campaign cards when the API/token fails.
|
||||
*/
|
||||
export async function fetchGoogleCreativeCards(opts: {
|
||||
tenantId: string;
|
||||
@@ -216,30 +227,44 @@ export async function fetchGoogleCreativeCards(opts: {
|
||||
until: string;
|
||||
limit?: number;
|
||||
}): Promise<{ ads: AdCreativeCard[]; warning?: string }> {
|
||||
if (!googleAdsConfigured()) {
|
||||
const limit = opts.limit ?? 24;
|
||||
const warehouseFallback = (warning: string) => {
|
||||
const ads = cardsFromDaily(
|
||||
opts.dailyRows,
|
||||
opts.since,
|
||||
opts.until,
|
||||
limit,
|
||||
'Preview desde warehouse (reconecta Google para assets)'
|
||||
);
|
||||
return {
|
||||
ads: [],
|
||||
warning: 'Google Ads API no configurada (GOOGLE_ADS_*)',
|
||||
ads,
|
||||
warning: friendlyGoogleCreativeWarning(
|
||||
ads.length ? warning : 'Sin ads Google en este periodo'
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
if (!googleAdsConfigured()) {
|
||||
return warehouseFallback(
|
||||
'Google Ads API no configurada — mostrando campañas del periodo'
|
||||
);
|
||||
}
|
||||
|
||||
const customerId = await resolveGoogleCustomerId(opts.tenantId);
|
||||
if (!customerId) {
|
||||
return { ads: [], warning: 'Cliente sin cuenta Google Ads enlazada' };
|
||||
return warehouseFallback('Cliente sin cuenta Google Ads enlazada');
|
||||
}
|
||||
|
||||
let accessToken: string;
|
||||
try {
|
||||
accessToken = await googleAccessToken();
|
||||
} catch (e) {
|
||||
return {
|
||||
ads: [],
|
||||
warning: e instanceof Error ? e.message : 'Token Google Ads falló',
|
||||
};
|
||||
return warehouseFallback(
|
||||
e instanceof Error ? e.message : 'Token Google Ads falló'
|
||||
);
|
||||
}
|
||||
|
||||
const dateWhere = `segments.date BETWEEN '${opts.since}' AND '${opts.until}'`;
|
||||
const limit = opts.limit ?? 24;
|
||||
|
||||
try {
|
||||
const [agAssets, agMetrics, adRows, adMetrics] = await Promise.all([
|
||||
@@ -554,39 +579,15 @@ WHERE ${dateWhere}
|
||||
.slice(0, limit);
|
||||
|
||||
if (!sliced.length) {
|
||||
const fallback = dailyAggs.slice(0, limit).map((agg) => ({
|
||||
platform: 'google' as const,
|
||||
adId: agg.adId,
|
||||
adName: agg.adName || agg.campaignName || agg.adId,
|
||||
campaignName: agg.campaignName,
|
||||
spend: Math.round(agg.spend * 100) / 100,
|
||||
impressions: agg.impressions,
|
||||
clicks: agg.clicks,
|
||||
conversions: agg.conversions,
|
||||
cpl: agg.conversions > 0 ? agg.spend / agg.conversions : null,
|
||||
mediaType: 'campaign',
|
||||
thumbnailUrl: '',
|
||||
imageUrl: '',
|
||||
headline: agg.campaignName || agg.adName,
|
||||
primaryText: 'Sin assets creativos en la API',
|
||||
headlines: [agg.campaignName || agg.adName].filter(Boolean),
|
||||
descriptions: ['Sin assets creativos en la API'],
|
||||
assets: [] as CreativeAsset[],
|
||||
status: undefined,
|
||||
}));
|
||||
return {
|
||||
ads: fallback,
|
||||
warning: fallback.length
|
||||
? 'Google: sin assets PMax/RSA; mostrando campañas de ad_daily'
|
||||
: 'Sin ads Google en este periodo',
|
||||
};
|
||||
return warehouseFallback(
|
||||
'Google: sin assets PMax/RSA; mostrando campañas de ad_daily'
|
||||
);
|
||||
}
|
||||
|
||||
return { ads: sliced };
|
||||
} catch (e) {
|
||||
return {
|
||||
ads: [],
|
||||
warning: e instanceof Error ? e.message : 'Error Google creatives',
|
||||
};
|
||||
return warehouseFallback(
|
||||
e instanceof Error ? e.message : 'Error Google creatives'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,10 +22,13 @@ const CREATIVE_FIELDS = [
|
||||
'effective_object_story_id',
|
||||
].join(',');
|
||||
|
||||
export type CreativeAspect = '9:16' | '4:5' | '16:9' | '1:1' | 'unknown';
|
||||
|
||||
export type CreativeAsset = {
|
||||
kind: 'image' | 'video' | 'logo' | 'youtube';
|
||||
url: string;
|
||||
label?: string;
|
||||
aspect?: CreativeAspect;
|
||||
youtubeId?: string;
|
||||
title?: string;
|
||||
};
|
||||
@@ -82,6 +85,28 @@ function uniq(arr: string[]): string[] {
|
||||
return [...new Set(arr.filter(Boolean))];
|
||||
}
|
||||
|
||||
export function inferCreativeAspect(
|
||||
label?: string,
|
||||
width?: number,
|
||||
height?: number
|
||||
): CreativeAspect {
|
||||
const L = (label || '').toLowerCase();
|
||||
if (/9\s*:?\s*16|story|reels|vertical|tall|portrait_9/.test(L)) return '9:16';
|
||||
if (/4\s*:?\s*5|portrait|feed_portrait/.test(L)) return '4:5';
|
||||
if (/16\s*:?\s*9|1\.91|landscape|horizontal|right_column|video/.test(L)) {
|
||||
return '16:9';
|
||||
}
|
||||
if (/1\s*:?\s*1|square/.test(L)) return '1:1';
|
||||
if (width && height && width > 0 && height > 0) {
|
||||
const r = width / height;
|
||||
if (r <= 0.62) return '9:16';
|
||||
if (r <= 0.9) return '4:5';
|
||||
if (r >= 1.5) return '16:9';
|
||||
if (Math.abs(r - 1) < 0.12) return '1:1';
|
||||
}
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
function detectMediaType(creative: Record<string, unknown>): string {
|
||||
if (creative.video_id) return 'video';
|
||||
const feed = creative.asset_feed_spec as Record<string, unknown> | undefined;
|
||||
@@ -113,74 +138,105 @@ function extractCreativePack(creative: Record<string, unknown>): Extracted {
|
||||
const s = str(h);
|
||||
if (/^[a-f0-9]{16,64}$/i.test(s)) hashes.push(s);
|
||||
};
|
||||
const pushImg = (url: unknown, label?: string) => {
|
||||
const pushImg = (
|
||||
url: unknown,
|
||||
label?: string,
|
||||
width?: number,
|
||||
height?: number
|
||||
) => {
|
||||
const u = str(url);
|
||||
if (!/^https?:\/\//i.test(u)) return;
|
||||
if (assets.some((a) => a.url === u)) return;
|
||||
assets.push({ kind: 'image', url: u, label });
|
||||
assets.push({
|
||||
kind: 'image',
|
||||
url: u,
|
||||
label,
|
||||
aspect: inferCreativeAspect(label, width, height),
|
||||
});
|
||||
};
|
||||
|
||||
if (creative.title) headlines.push(str(creative.title));
|
||||
if (creative.body) descriptions.push(str(creative.body));
|
||||
pushImg(creative.image_url, 'image');
|
||||
pushImg(creative.thumbnail_url, 'thumbnail');
|
||||
pushHash(creative.image_hash);
|
||||
|
||||
// Primary serving copy = object_story_spec (what Ads Manager shows as the ad).
|
||||
// Do NOT dump every asset_feed_spec A/B variant into the card.
|
||||
const oss = creative.object_story_spec as Record<string, unknown> | undefined;
|
||||
const link = oss?.link_data as Record<string, unknown> | undefined;
|
||||
const video = oss?.video_data as Record<string, unknown> | undefined;
|
||||
const photo = oss?.photo_data as Record<string, unknown> | undefined;
|
||||
|
||||
if (link?.name) headlines.push(str(link.name));
|
||||
if (link?.message) descriptions.push(str(link.message));
|
||||
if (link?.description) descriptions.push(str(link.description));
|
||||
if (link?.caption) descriptions.push(str(link.caption));
|
||||
pushImg(link?.picture, 'story');
|
||||
pushImg(link?.image_url, 'story');
|
||||
else if (link?.description) descriptions.push(str(link.description));
|
||||
pushImg(link?.picture, '4:5 story', 1080, 1350);
|
||||
pushImg(link?.image_url, '4:5 story', 1080, 1350);
|
||||
pushHash(link?.image_hash);
|
||||
|
||||
if (video?.title) headlines.push(str(video.title));
|
||||
if (video?.message) descriptions.push(str(video.message));
|
||||
if (video?.link_description) descriptions.push(str(video.link_description));
|
||||
pushImg(video?.image_url, 'video_poster');
|
||||
else if (video?.link_description) descriptions.push(str(video.link_description));
|
||||
pushImg(video?.image_url, 'video_poster 9:16', 1080, 1920);
|
||||
pushHash(video?.image_hash);
|
||||
pushImg(photo?.url, 'photo');
|
||||
|
||||
pushImg(photo?.url, 'photo 4:5', 1080, 1350);
|
||||
pushHash(photo?.image_hash);
|
||||
|
||||
if (!headlines.length && creative.title) headlines.push(str(creative.title));
|
||||
if (!descriptions.length && creative.body) descriptions.push(str(creative.body));
|
||||
pushImg(creative.image_url, 'image 4:5', 1080, 1350);
|
||||
pushImg(creative.thumbnail_url, 'thumbnail 9:16', 1080, 1920);
|
||||
pushHash(creative.image_hash);
|
||||
|
||||
// Carousel: keep images; only first card's name as optional secondary headline.
|
||||
if (Array.isArray(link?.child_attachments)) {
|
||||
(link!.child_attachments as Record<string, unknown>[]).forEach((c, i) => {
|
||||
pushImg(c.picture || c.image_url, `carousel-${i + 1}`);
|
||||
pushImg(c.picture || c.image_url, `carousel-${i + 1} 4:5`, 1080, 1350);
|
||||
pushHash(c.image_hash);
|
||||
if (c.name) headlines.push(str(c.name));
|
||||
if (c.description) descriptions.push(str(c.description));
|
||||
if (c.message) descriptions.push(str(c.message));
|
||||
if (i === 0 && c.name && !headlines.length) headlines.push(str(c.name));
|
||||
if (i === 0 && c.description && !descriptions.length) {
|
||||
descriptions.push(str(c.description));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const feed = creative.asset_feed_spec as Record<string, unknown> | undefined;
|
||||
if (feed) {
|
||||
for (const t of (feed.titles as { text?: string }[]) || []) {
|
||||
if (t?.text) headlines.push(str(t.text));
|
||||
}
|
||||
for (const b of (feed.bodies as { text?: string }[]) || []) {
|
||||
if (b?.text) descriptions.push(str(b.text));
|
||||
}
|
||||
for (const d of (feed.descriptions as { text?: string }[]) || []) {
|
||||
if (d?.text) descriptions.push(str(d.text));
|
||||
}
|
||||
// Images/videos from dynamic creative — label formats; texts only if still empty.
|
||||
for (const img of (feed.images as Record<string, unknown>[]) || []) {
|
||||
pushImg(img.url || img.permalink_url, 'feed');
|
||||
const labels = Array.isArray(img.adlabels)
|
||||
? (img.adlabels as { name?: string }[])
|
||||
.map((l) => str(l.name))
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
: '';
|
||||
const tag = labels || 'feed';
|
||||
pushImg(
|
||||
img.url || img.permalink_url,
|
||||
tag,
|
||||
n(img.width) || undefined,
|
||||
n(img.height) || undefined
|
||||
);
|
||||
pushHash(img.hash);
|
||||
}
|
||||
for (const v of (feed.videos as Record<string, unknown>[]) || []) {
|
||||
pushImg(v.thumbnail_url || v.picture, 'video_feed');
|
||||
pushImg(v.thumbnail_url || v.picture, 'video_feed 9:16', 1080, 1920);
|
||||
if (v.video_id) {
|
||||
assets.push({
|
||||
kind: 'video',
|
||||
url: str(v.thumbnail_url || v.picture),
|
||||
label: 'video',
|
||||
label: 'video 9:16',
|
||||
title: str(v.video_id),
|
||||
aspect: '9:16',
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!headlines.length) {
|
||||
const t = (feed.titles as { text?: string }[] | undefined)?.[0]?.text;
|
||||
if (t) headlines.push(str(t));
|
||||
}
|
||||
if (!descriptions.length) {
|
||||
const b =
|
||||
(feed.bodies as { text?: string }[] | undefined)?.[0]?.text ||
|
||||
(feed.descriptions as { text?: string }[] | undefined)?.[0]?.text;
|
||||
if (b) descriptions.push(str(b));
|
||||
}
|
||||
}
|
||||
|
||||
const linkUrl =
|
||||
@@ -192,8 +248,8 @@ function extractCreativePack(creative: Record<string, unknown>): Extracted {
|
||||
const cta = str(creative.call_to_action_type) || str(ctaRaw);
|
||||
|
||||
return {
|
||||
headlines: uniq(headlines),
|
||||
descriptions: uniq(descriptions),
|
||||
headlines: uniq(headlines).slice(0, 3),
|
||||
descriptions: uniq(descriptions).slice(0, 3),
|
||||
assets,
|
||||
linkUrl,
|
||||
cta,
|
||||
@@ -201,15 +257,20 @@ function extractCreativePack(creative: Record<string, unknown>): Extracted {
|
||||
};
|
||||
}
|
||||
|
||||
/** Prefer portrait/vertical-looking assets; else first image. */
|
||||
/** Prefer 9:16 story; then 4:5; else first image. */
|
||||
function pickVerticalPreview(assets: CreativeAsset[], fallback: string): string {
|
||||
const images = assets.filter((a) => a.kind === 'image' && a.url);
|
||||
const portrait = images.find((a) =>
|
||||
/portrait|vertical|9.?16|4.?5|tall|story/i.test(a.label || '')
|
||||
const nine = images.find((a) => a.aspect === '9:16');
|
||||
if (nine) return nine.url;
|
||||
const portrait = images.find(
|
||||
(a) =>
|
||||
a.aspect === '4:5' ||
|
||||
/portrait|vertical|9.?16|4.?5|tall|story/i.test(a.label || '')
|
||||
);
|
||||
if (portrait) return portrait.url;
|
||||
// Meta often serves taller thumbnails when requested 1080x1920 — thumbnail label last
|
||||
const nonSquare = images.find((a) => a.label === 'thumbnail' || a.label === 'video_poster');
|
||||
const nonSquare = images.find(
|
||||
(a) => a.label?.includes('thumbnail') || a.label?.includes('video_poster')
|
||||
);
|
||||
if (nonSquare) return nonSquare.url;
|
||||
return images[0]?.url || fallback;
|
||||
}
|
||||
|
||||
@@ -133,36 +133,8 @@ async function metaToken(): Promise<string> {
|
||||
}
|
||||
|
||||
async function googleAdsAccessToken(): Promise<string> {
|
||||
const refresh =
|
||||
process.env.GOOGLE_ADS_REFRESH_TOKEN?.trim() ||
|
||||
process.env.GOOGLE_OAUTH_REFRESH_TOKEN?.trim();
|
||||
const clientId =
|
||||
process.env.GOOGLE_ADS_CLIENT_ID?.trim() ||
|
||||
process.env.GOOGLE_OAUTH_CLIENT_ID?.trim();
|
||||
const clientSecret =
|
||||
process.env.GOOGLE_ADS_CLIENT_SECRET?.trim() ||
|
||||
process.env.GOOGLE_OAUTH_CLIENT_SECRET?.trim();
|
||||
if (!refresh || !clientId || !clientSecret) {
|
||||
throw new Error(
|
||||
'Faltan GOOGLE_ADS_REFRESH_TOKEN + CLIENT_ID/SECRET (o GOOGLE_OAUTH_*)'
|
||||
);
|
||||
}
|
||||
const body = new URLSearchParams({
|
||||
refresh_token: refresh,
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
grant_type: 'refresh_token',
|
||||
});
|
||||
const r = await fetch('https://oauth2.googleapis.com/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body,
|
||||
});
|
||||
if (!r.ok) {
|
||||
throw new Error(`Google Ads token: ${(await r.text()).slice(0, 200)}`);
|
||||
}
|
||||
const j = (await r.json()) as { access_token: string };
|
||||
return j.access_token;
|
||||
const { resolveGoogleAdsAccessToken } = await import('@/lib/google-ads-auth');
|
||||
return resolveGoogleAdsAccessToken();
|
||||
}
|
||||
|
||||
export async function listMetaAdAccounts(): Promise<NamedAccount[]> {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { serviceHeaders, supabaseUrl } from '@/lib/agency';
|
||||
import { resolveMetaAccessToken } from '@/lib/ads-discovery';
|
||||
import { leadFromMetaActions, nMeta } from '@/lib/meta-leads';
|
||||
import { resolveGoogleAdsAccessToken } from '@/lib/google-ads-auth';
|
||||
|
||||
const META_GRAPH = 'https://graph.facebook.com/v21.0';
|
||||
const GADS_API = 'https://googleads.googleapis.com/v22';
|
||||
@@ -66,32 +67,7 @@ function isoWeekMonday(d: Date): string {
|
||||
}
|
||||
|
||||
async function googleAccessToken(): Promise<string> {
|
||||
const refresh =
|
||||
process.env.GOOGLE_ADS_REFRESH_TOKEN?.trim() ||
|
||||
process.env.GOOGLE_OAUTH_REFRESH_TOKEN?.trim();
|
||||
const clientId =
|
||||
process.env.GOOGLE_ADS_CLIENT_ID?.trim() ||
|
||||
process.env.GOOGLE_OAUTH_CLIENT_ID?.trim();
|
||||
const clientSecret =
|
||||
process.env.GOOGLE_ADS_CLIENT_SECRET?.trim() ||
|
||||
process.env.GOOGLE_OAUTH_CLIENT_SECRET?.trim();
|
||||
if (!refresh || !clientId || !clientSecret) {
|
||||
throw new Error('Faltan credenciales Google Ads');
|
||||
}
|
||||
const body = new URLSearchParams({
|
||||
refresh_token: refresh,
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
grant_type: 'refresh_token',
|
||||
});
|
||||
const r = await fetch('https://oauth2.googleapis.com/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body,
|
||||
});
|
||||
if (!r.ok) throw new Error(`Google token: ${(await r.text()).slice(0, 180)}`);
|
||||
const j = (await r.json()) as { access_token: string };
|
||||
return j.access_token;
|
||||
return resolveGoogleAdsAccessToken();
|
||||
}
|
||||
|
||||
function gadsHeaders(accessToken: string, loginCustomerId?: string) {
|
||||
|
||||
146
src/lib/google-ads-auth.ts
Normal file
146
src/lib/google-ads-auth.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
/** Shared Google Ads OAuth access token resolution. */
|
||||
|
||||
import { serviceHeaders, supabaseUrl } from '@/lib/agency';
|
||||
|
||||
const ADS_SCOPE = 'https://www.googleapis.com/auth/adwords';
|
||||
|
||||
type TokenAttempt = {
|
||||
refresh: string;
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
async function refreshAccess(attempt: TokenAttempt): Promise<string> {
|
||||
const body = new URLSearchParams({
|
||||
refresh_token: attempt.refresh,
|
||||
client_id: attempt.clientId,
|
||||
client_secret: attempt.clientSecret,
|
||||
grant_type: 'refresh_token',
|
||||
});
|
||||
const r = await fetch('https://oauth2.googleapis.com/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body,
|
||||
});
|
||||
if (!r.ok) {
|
||||
throw new Error(
|
||||
`Google token (${attempt.label}): ${(await r.text()).slice(0, 180)}`
|
||||
);
|
||||
}
|
||||
return ((await r.json()) as { access_token: string }).access_token;
|
||||
}
|
||||
|
||||
function adsClientPair(): { clientId: string; clientSecret: string } | null {
|
||||
const clientId =
|
||||
process.env.GOOGLE_ADS_CLIENT_ID?.trim() ||
|
||||
process.env.GOOGLE_OAUTH_CLIENT_ID?.trim();
|
||||
const clientSecret =
|
||||
process.env.GOOGLE_ADS_CLIENT_SECRET?.trim() ||
|
||||
process.env.GOOGLE_OAUTH_CLIENT_SECRET?.trim();
|
||||
if (!clientId || !clientSecret) return null;
|
||||
return { clientId, clientSecret };
|
||||
}
|
||||
|
||||
function oauthClientPair(): { clientId: string; clientSecret: string } | null {
|
||||
const clientId = process.env.GOOGLE_OAUTH_CLIENT_ID?.trim();
|
||||
const clientSecret = process.env.GOOGLE_OAUTH_CLIENT_SECRET?.trim();
|
||||
if (!clientId || !clientSecret) return null;
|
||||
return { clientId, clientSecret };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a Google Ads API access token.
|
||||
* Order: env GOOGLE_ADS_REFRESH → agency_oauth google_ads → agency_oauth google_drive (si tiene scope Ads).
|
||||
*/
|
||||
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 {
|
||||
const r = await fetch(
|
||||
`${supabaseUrl()}/rest/v1/agency_oauth?provider=in.(google_ads,google_drive)&select=provider,refresh_token,scopes`,
|
||||
{ headers, cache: 'no-store' }
|
||||
);
|
||||
if (r.ok) {
|
||||
const rows = (await r.json()) as {
|
||||
provider?: string;
|
||||
refresh_token?: string | null;
|
||||
scopes?: string | null;
|
||||
}[];
|
||||
for (const row of rows) {
|
||||
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',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
if (!attempts.length) {
|
||||
throw new Error(
|
||||
'Faltan credenciales Google Ads (GOOGLE_ADS_REFRESH_TOKEN o reconectar Agencia → Drive con Ads)'
|
||||
);
|
||||
}
|
||||
|
||||
const errors: string[] = [];
|
||||
for (const attempt of attempts) {
|
||||
try {
|
||||
return await refreshAccess(attempt);
|
||||
} catch (e) {
|
||||
errors.push(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}
|
||||
const joined = errors.join(' | ');
|
||||
if (/invalid_grant|expired|revoked/i.test(joined)) {
|
||||
throw new Error(
|
||||
'Google OAuth caducado — reconecta en Agencia → Drive (incluye Ads)'
|
||||
);
|
||||
}
|
||||
throw new Error(joined.slice(0, 220) || 'Token Google Ads falló');
|
||||
}
|
||||
|
||||
export function friendlyGoogleCreativeWarning(raw: string): string {
|
||||
if (/invalid_grant|caducado|expired|revoked/i.test(raw)) {
|
||||
return 'Google OAuth caducado — reconecta en Agencia → Drive (incluye Ads). Mostrando campañas del periodo.';
|
||||
}
|
||||
return raw.length > 180 ? `${raw.slice(0, 180)}…` : raw;
|
||||
}
|
||||
@@ -4,11 +4,12 @@ import { serviceHeaders, supabaseUrl } from '@/lib/agency';
|
||||
|
||||
export const DRIVE_PROVIDER = 'google_drive';
|
||||
|
||||
/** Unified agency Google scopes: Drive + GA4 + Search Console. */
|
||||
/** Unified agency Google scopes: Drive + GA4 + Search Console + Ads. */
|
||||
export const DRIVE_SCOPES = [
|
||||
'https://www.googleapis.com/auth/drive',
|
||||
'https://www.googleapis.com/auth/analytics.readonly',
|
||||
'https://www.googleapis.com/auth/webmasters.readonly',
|
||||
'https://www.googleapis.com/auth/adwords',
|
||||
'openid',
|
||||
'email',
|
||||
'profile',
|
||||
@@ -172,6 +173,7 @@ export async function getAgencyOAuthStatus(): Promise<{
|
||||
hasAnalytics?: boolean;
|
||||
hasSearchConsole?: boolean;
|
||||
hasDrive?: boolean;
|
||||
hasAds?: boolean;
|
||||
}> {
|
||||
const configured = driveConfigured();
|
||||
const headers = serviceHeaders();
|
||||
@@ -202,6 +204,9 @@ export async function getAgencyOAuthStatus(): Promise<{
|
||||
hasSearchConsole:
|
||||
scopes.includes('webmasters') || scopes.includes('searchconsole'),
|
||||
hasDrive: scopes.includes('drive') || !scopes,
|
||||
hasAds:
|
||||
scopes.includes('adwords') ||
|
||||
scopes.includes('https://www.googleapis.com/auth/adwords'),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user