fix: previews Meta vía proxy y creatividades Google PMax/RSA
Las imágenes Meta dejan de romperse en el navegador; Google trae assets en vivo. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -5,9 +5,9 @@ import { isAgencyRole, userRole } from '@/lib/agency';
|
|||||||
import { AdDailyRow } from '@/lib/metrics';
|
import { AdDailyRow } from '@/lib/metrics';
|
||||||
import {
|
import {
|
||||||
AdCreativeCard,
|
AdCreativeCard,
|
||||||
buildGoogleCardsFromDaily,
|
|
||||||
fetchMetaCreativeCards,
|
fetchMetaCreativeCards,
|
||||||
} from '@/lib/ad-creatives-meta';
|
} from '@/lib/ad-creatives-meta';
|
||||||
|
import { fetchGoogleCreativeCards } from '@/lib/ad-creatives-google';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
@@ -73,14 +73,22 @@ export async function GET(req: NextRequest) {
|
|||||||
dailyRows: daily,
|
dailyRows: daily,
|
||||||
since,
|
since,
|
||||||
until,
|
until,
|
||||||
limit: 40,
|
limit: 16,
|
||||||
});
|
});
|
||||||
metaAds = meta.ads;
|
metaAds = meta.ads;
|
||||||
if (meta.warning) warnings.push(meta.warning);
|
if (meta.warning) warnings.push(meta.warning);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (platform === 'google' || platform === 'all') {
|
if (platform === 'google' || platform === 'all') {
|
||||||
googleAds = buildGoogleCardsFromDaily(daily, since, until, 40);
|
const google = await fetchGoogleCreativeCards({
|
||||||
|
tenantId: ctx.tenantId,
|
||||||
|
dailyRows: daily,
|
||||||
|
since,
|
||||||
|
until,
|
||||||
|
limit: 24,
|
||||||
|
});
|
||||||
|
googleAds = google.ads;
|
||||||
|
if (google.warning) warnings.push(google.warning);
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
|
|||||||
81
src/app/api/ad-tracker/media/route.ts
Normal file
81
src/app/api/ad-tracker/media/route.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { createClient } from '@/lib/supabase/server';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
/** CDN prefixes safe to proxy for Ad Tracker previews. */
|
||||||
|
const ALLOWED = [
|
||||||
|
'https://scontent.',
|
||||||
|
'https://external.',
|
||||||
|
'https://facebook.com/',
|
||||||
|
'https://www.facebook.com/',
|
||||||
|
'https://fbcdn.net/',
|
||||||
|
'https://scontent.xx.fbcdn.net/',
|
||||||
|
'https://tpc.googlesyndication.com/',
|
||||||
|
'https://lh3.googleusercontent.com/',
|
||||||
|
'https://i.ytimg.com/',
|
||||||
|
'https://img.youtube.com/',
|
||||||
|
];
|
||||||
|
|
||||||
|
function allowed(url: string): boolean {
|
||||||
|
if (!url.startsWith('https://')) return false;
|
||||||
|
if (ALLOWED.some((p) => url.startsWith(p))) return true;
|
||||||
|
// Meta CDN hosts vary by region: scontent.fmad19-2.fna.fbcdn.net
|
||||||
|
try {
|
||||||
|
const host = new URL(url).hostname;
|
||||||
|
return (
|
||||||
|
host.endsWith('.fbcdn.net') ||
|
||||||
|
host.endsWith('.facebook.com') ||
|
||||||
|
host.endsWith('.googleusercontent.com') ||
|
||||||
|
host.endsWith('.googlesyndication.com') ||
|
||||||
|
host === 'i.ytimg.com' ||
|
||||||
|
host === 'img.youtube.com'
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/ad-tracker/media?url=…
|
||||||
|
* Proxies Meta/Google creative images so the browser always gets a same-origin preview.
|
||||||
|
*/
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const supabase = await createClient();
|
||||||
|
const {
|
||||||
|
data: { user },
|
||||||
|
} = await supabase.auth.getUser();
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = req.nextUrl.searchParams.get('url') || '';
|
||||||
|
if (!allowed(url)) {
|
||||||
|
return NextResponse.json({ error: 'url_not_allowed' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
'User-Agent':
|
||||||
|
'Mozilla/5.0 (compatible; 4VenturesAdTracker/1.0; +https://app.4ventures.es)',
|
||||||
|
Accept: 'image/*,*/*;q=0.8',
|
||||||
|
},
|
||||||
|
cache: 'force-cache',
|
||||||
|
next: { revalidate: 86400 },
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
return NextResponse.json({ error: 'upstream' }, { status: 502 });
|
||||||
|
}
|
||||||
|
const contentType = res.headers.get('content-type') || 'image/jpeg';
|
||||||
|
const body = await res.arrayBuffer();
|
||||||
|
return new NextResponse(body, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': contentType,
|
||||||
|
'Cache-Control': 'private, max-age=3600',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'fetch_failed' }, { status: 502 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -304,6 +304,12 @@ function CampaignTable({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function proxiedMediaUrl(raw: string): string {
|
||||||
|
if (!raw) return '';
|
||||||
|
if (raw.startsWith('/api/')) return raw;
|
||||||
|
return `/api/ad-tracker/media?url=${encodeURIComponent(raw)}`;
|
||||||
|
}
|
||||||
|
|
||||||
function CreativesGallery({
|
function CreativesGallery({
|
||||||
platform,
|
platform,
|
||||||
ads,
|
ads,
|
||||||
@@ -316,23 +322,42 @@ function CreativesGallery({
|
|||||||
warning?: string | null;
|
warning?: string | null;
|
||||||
}) {
|
}) {
|
||||||
const filtered = ads.filter((a) => a.platform === platform);
|
const filtered = ads.filter((a) => a.platform === platform);
|
||||||
|
const platformWarning =
|
||||||
|
warning
|
||||||
|
?.split(' · ')
|
||||||
|
.find((w) =>
|
||||||
|
platform === 'meta'
|
||||||
|
? /meta|token meta|sin ads meta/i.test(w)
|
||||||
|
: /google|pmax|rsa|sin ads google|sin assets/i.test(w)
|
||||||
|
) || (platform === 'meta' || platform === 'google' ? null : null);
|
||||||
|
|
||||||
|
// Prefer full warning on matching platform; otherwise only platform-relevant part
|
||||||
|
const shownWarning =
|
||||||
|
filtered.length === 0 && warning
|
||||||
|
? platformWarning ||
|
||||||
|
(ads.length === 0 ? warning : null)
|
||||||
|
: platformWarning && filtered.length === 0
|
||||||
|
? platformWarning
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="space-y-3">
|
<section className="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-[10px] uppercase tracking-wider text-gray-500">
|
<p className="text-[10px] uppercase tracking-wider text-gray-500">
|
||||||
Creatividades · estilo Brava Ad Tracker
|
Creatividades · preview por anuncio
|
||||||
</p>
|
</p>
|
||||||
<h3 className="text-lg font-bold text-white">
|
<h3 className="text-lg font-bold text-white">
|
||||||
{platform === 'meta' ? 'Meta Ads' : 'Google Ads'} · preview por anuncio
|
{platform === 'meta' ? 'Meta Ads' : 'Google Ads'} · creatividades
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
Preview serving / asset + KPIs del rango. Meta tira creatividades en
|
{platform === 'meta'
|
||||||
vivo; Google muestra ficha (assets RSA en siguiente iteración).
|
? 'Imagen / vídeo desde Meta Graph (proxy same-origin).'
|
||||||
|
: 'Assets PMax e RSA en vivo desde Google Ads API.'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{warning ? (
|
{shownWarning ? (
|
||||||
<p className="text-xs text-amber-400/90 border border-amber-500/20 rounded-lg px-3 py-2">
|
<p className="text-xs text-amber-400/90 border border-amber-500/20 rounded-lg px-3 py-2">
|
||||||
{warning}
|
{shownWarning}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
{loading ? (
|
{loading ? (
|
||||||
@@ -355,8 +380,21 @@ function CreativesGallery({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function CreativeCard({ ad }: { ad: AdCreativeCard }) {
|
function CreativeCard({ ad }: { ad: AdCreativeCard }) {
|
||||||
const preview = ad.imageUrl || ad.thumbnailUrl;
|
const raw = ad.imageUrl || ad.thumbnailUrl;
|
||||||
const isVideo = ad.mediaType === 'video';
|
const ytPoster = ad.youtubeId
|
||||||
|
? proxiedMediaUrl(`https://i.ytimg.com/vi/${ad.youtubeId}/hqdefault.jpg`)
|
||||||
|
: '';
|
||||||
|
const preview = raw ? proxiedMediaUrl(raw) : ytPoster;
|
||||||
|
const isVideo = ad.mediaType === 'video' || Boolean(ad.youtubeId);
|
||||||
|
const headlines = (ad.headline || '')
|
||||||
|
.split('\n')
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
const bodies = (ad.primaryText || '')
|
||||||
|
.split('\n')
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article className="grid gap-4 rounded-2xl border border-gray-800 bg-[#111827] p-4 md:grid-cols-[minmax(220px,320px)_1fr]">
|
<article className="grid gap-4 rounded-2xl border border-gray-800 bg-[#111827] p-4 md:grid-cols-[minmax(220px,320px)_1fr]">
|
||||||
<div className="relative overflow-hidden rounded-xl bg-black/40 border border-gray-800 min-h-[220px] flex items-center justify-center">
|
<div className="relative overflow-hidden rounded-xl bg-black/40 border border-gray-800 min-h-[220px] flex items-center justify-center">
|
||||||
@@ -367,19 +405,23 @@ function CreativeCard({ ad }: { ad: AdCreativeCard }) {
|
|||||||
alt={ad.adName}
|
alt={ad.adName}
|
||||||
className="w-full h-full object-cover max-h-[420px]"
|
className="w-full h-full object-cover max-h-[420px]"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
referrerPolicy="no-referrer"
|
|
||||||
/>
|
/>
|
||||||
) : ad.platform === 'google' ? (
|
) : ad.platform === 'google' && headlines.length ? (
|
||||||
<div className="w-full p-4 space-y-2 text-left">
|
<div className="w-full p-4 space-y-2 text-left bg-gradient-to-b from-[#1a2332] to-[#0d121f]">
|
||||||
<p className="text-[10px] uppercase tracking-wider text-sky-400">
|
<p className="text-[10px] uppercase tracking-wider text-sky-400">
|
||||||
Google Ads · {ad.mediaType || 'rsa'}
|
Google Ads · {ad.mediaType || 'rsa'}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm font-semibold text-blue-300 leading-snug">
|
{headlines.slice(0, 3).map((h) => (
|
||||||
{(ad.headline || ad.adName).split('\n')[0]}
|
<p
|
||||||
</p>
|
key={h}
|
||||||
<p className="text-xs text-gray-400 line-clamp-3">
|
className="text-sm font-semibold text-blue-300 leading-snug"
|
||||||
{(ad.primaryText || ad.campaignName || '').split('\n')[0]}
|
>
|
||||||
|
{h}
|
||||||
</p>
|
</p>
|
||||||
|
))}
|
||||||
|
{bodies[0] ? (
|
||||||
|
<p className="text-xs text-gray-400 line-clamp-3">{bodies[0]}</p>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-center text-gray-500 text-xs px-4">
|
<div className="text-center text-gray-500 text-xs px-4">
|
||||||
@@ -424,17 +466,15 @@ function CreativeCard({ ad }: { ad: AdCreativeCard }) {
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{(ad.headline || ad.primaryText) && (
|
{(headlines.length > 0 || bodies.length > 0) && (
|
||||||
<div className="space-y-1 text-xs">
|
<div className="space-y-1 text-xs">
|
||||||
{ad.headline ? (
|
{headlines[0] ? (
|
||||||
<p className="text-gray-200 font-medium line-clamp-2">
|
<p className="text-gray-200 font-medium line-clamp-2">
|
||||||
{ad.headline.split('\n')[0]}
|
{headlines[0]}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
{ad.primaryText ? (
|
{bodies[0] ? (
|
||||||
<p className="text-gray-500 line-clamp-3">
|
<p className="text-gray-500 line-clamp-3">{bodies[0]}</p>
|
||||||
{ad.primaryText.split('\n')[0]}
|
|
||||||
</p>
|
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -731,6 +771,19 @@ export default function AdTrackerClient({
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<CreativesGallery
|
||||||
|
platform="meta"
|
||||||
|
ads={creatives}
|
||||||
|
loading={creativesLoading}
|
||||||
|
warning={creativesWarning}
|
||||||
|
/>
|
||||||
|
<CreativesGallery
|
||||||
|
platform="google"
|
||||||
|
ads={creatives}
|
||||||
|
loading={creativesLoading}
|
||||||
|
warning={creativesWarning}
|
||||||
|
/>
|
||||||
|
|
||||||
<section className="rounded-2xl border border-gray-800 bg-[#111827] p-4 md:p-5">
|
<section className="rounded-2xl border border-gray-800 bg-[#111827] p-4 md:p-5">
|
||||||
<div className="flex flex-wrap items-start justify-between gap-3 mb-3">
|
<div className="flex flex-wrap items-start justify-between gap-3 mb-3">
|
||||||
<div>
|
<div>
|
||||||
@@ -809,19 +862,6 @@ export default function AdTrackerClient({
|
|||||||
empty="Sin campañas Google activas en este periodo"
|
empty="Sin campañas Google activas en este periodo"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CreativesGallery
|
|
||||||
platform="meta"
|
|
||||||
ads={creatives}
|
|
||||||
loading={creativesLoading}
|
|
||||||
warning={creativesWarning}
|
|
||||||
/>
|
|
||||||
<CreativesGallery
|
|
||||||
platform="google"
|
|
||||||
ads={creatives}
|
|
||||||
loading={false}
|
|
||||||
warning={null}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -836,6 +876,12 @@ export default function AdTrackerClient({
|
|||||||
value={meta.cpl != null ? formatEur(meta.cpl) : '—'}
|
value={meta.cpl != null ? formatEur(meta.cpl) : '—'}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<CreativesGallery
|
||||||
|
platform="meta"
|
||||||
|
ads={creatives}
|
||||||
|
loading={creativesLoading}
|
||||||
|
warning={creativesWarning}
|
||||||
|
/>
|
||||||
<section className="rounded-2xl border border-gray-800 bg-[#111827] p-4">
|
<section className="rounded-2xl border border-gray-800 bg-[#111827] p-4">
|
||||||
<h3 className="text-lg font-bold text-white mb-3">
|
<h3 className="text-lg font-bold text-white mb-3">
|
||||||
Conversiones y CPL · Meta
|
Conversiones y CPL · Meta
|
||||||
@@ -853,12 +899,6 @@ export default function AdTrackerClient({
|
|||||||
rows={metaCampaigns}
|
rows={metaCampaigns}
|
||||||
empty="Sin campañas Meta activas en este periodo"
|
empty="Sin campañas Meta activas en este periodo"
|
||||||
/>
|
/>
|
||||||
<CreativesGallery
|
|
||||||
platform="meta"
|
|
||||||
ads={creatives}
|
|
||||||
loading={creativesLoading}
|
|
||||||
warning={creativesWarning}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -873,6 +913,12 @@ export default function AdTrackerClient({
|
|||||||
value={google.cpl != null ? formatEur(google.cpl) : '—'}
|
value={google.cpl != null ? formatEur(google.cpl) : '—'}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<CreativesGallery
|
||||||
|
platform="google"
|
||||||
|
ads={creatives}
|
||||||
|
loading={creativesLoading}
|
||||||
|
warning={creativesWarning}
|
||||||
|
/>
|
||||||
<section className="rounded-2xl border border-gray-800 bg-[#111827] p-4">
|
<section className="rounded-2xl border border-gray-800 bg-[#111827] p-4">
|
||||||
<h3 className="text-lg font-bold text-white mb-3">
|
<h3 className="text-lg font-bold text-white mb-3">
|
||||||
Conversiones y CPL · Google
|
Conversiones y CPL · Google
|
||||||
@@ -890,12 +936,6 @@ export default function AdTrackerClient({
|
|||||||
rows={googleCampaigns}
|
rows={googleCampaigns}
|
||||||
empty="Sin campañas Google activas en este periodo"
|
empty="Sin campañas Google activas en este periodo"
|
||||||
/>
|
/>
|
||||||
<CreativesGallery
|
|
||||||
platform="google"
|
|
||||||
ads={creatives}
|
|
||||||
loading={creativesLoading}
|
|
||||||
warning={creativesWarning}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
509
src/lib/ad-creatives-google.ts
Normal file
509
src/lib/ad-creatives-google.ts
Normal file
@@ -0,0 +1,509 @@
|
|||||||
|
/** Google Ads creatives for Ad Tracker (PMax asset groups + RSA/RDA). */
|
||||||
|
|
||||||
|
import { googleAdsConfigured } from '@/lib/ads-discovery';
|
||||||
|
import { serviceHeaders, supabaseUrl } from '@/lib/agency';
|
||||||
|
import type { AdCreativeCard } from '@/lib/ad-creatives-meta';
|
||||||
|
import { aggregateDailyByAd } from '@/lib/ad-creatives-meta';
|
||||||
|
|
||||||
|
const GADS_API = 'https://googleads.googleapis.com/v22';
|
||||||
|
|
||||||
|
type GaqlRow = {
|
||||||
|
campaign?: { id?: string; name?: string; status?: string };
|
||||||
|
adGroup?: { id?: string; name?: string };
|
||||||
|
adGroupAd?: {
|
||||||
|
status?: string;
|
||||||
|
ad?: {
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
type?: string;
|
||||||
|
finalUrls?: string[];
|
||||||
|
responsiveSearchAd?: {
|
||||||
|
headlines?: { text?: string }[];
|
||||||
|
descriptions?: { text?: string }[];
|
||||||
|
};
|
||||||
|
responsiveDisplayAd?: {
|
||||||
|
headlines?: { text?: string }[];
|
||||||
|
longHeadline?: { text?: string };
|
||||||
|
descriptions?: { text?: string }[];
|
||||||
|
businessName?: string;
|
||||||
|
marketingImages?: { asset?: string }[];
|
||||||
|
squareMarketingImages?: { asset?: string }[];
|
||||||
|
};
|
||||||
|
imageAd?: { imageUrl?: string; name?: string };
|
||||||
|
};
|
||||||
|
};
|
||||||
|
assetGroup?: {
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
status?: string;
|
||||||
|
finalUrls?: string[];
|
||||||
|
};
|
||||||
|
assetGroupAsset?: { fieldType?: string; status?: string };
|
||||||
|
asset?: {
|
||||||
|
id?: string;
|
||||||
|
type?: string;
|
||||||
|
textAsset?: { text?: string };
|
||||||
|
imageAsset?: { fullSize?: { url?: string } };
|
||||||
|
youtubeVideoAsset?: {
|
||||||
|
youtubeVideoId?: string;
|
||||||
|
youtubeVideoTitle?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
metrics?: {
|
||||||
|
costMicros?: string;
|
||||||
|
impressions?: string;
|
||||||
|
clicks?: string;
|
||||||
|
conversions?: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
function n(v: unknown): number {
|
||||||
|
const x = Number(v);
|
||||||
|
return Number.isFinite(x) ? x : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function str(v: unknown): string {
|
||||||
|
return v == null ? '' : String(v).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function enumName(v?: string): string {
|
||||||
|
return (v || '').replace(/^[A-Z0-9]+_/, '') || str(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
function gadsHeaders(
|
||||||
|
accessToken: string,
|
||||||
|
loginCustomerId?: string
|
||||||
|
): Record<string, string> {
|
||||||
|
const h: Record<string, string> = {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
'developer-token': process.env.GOOGLE_ADS_DEVELOPER_TOKEN!.trim(),
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
};
|
||||||
|
const login =
|
||||||
|
loginCustomerId ||
|
||||||
|
process.env.GOOGLE_ADS_LOGIN_CUSTOMER_ID?.trim()?.replace(/-/g, '');
|
||||||
|
if (login) h['login-customer-id'] = login;
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveGoogleCustomerId(tenantId: string): Promise<string | null> {
|
||||||
|
const headers = serviceHeaders();
|
||||||
|
const base = supabaseUrl();
|
||||||
|
if (!headers || !base) return null;
|
||||||
|
const r = await fetch(
|
||||||
|
`${base}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(tenantId)}&provider=eq.google_ads&select=external_account_id,status&limit=1`,
|
||||||
|
{ headers, cache: 'no-store' }
|
||||||
|
);
|
||||||
|
if (!r.ok) return null;
|
||||||
|
const rows = (await r.json()) as { external_account_id?: string | null }[];
|
||||||
|
const id = rows[0]?.external_account_id?.trim()?.replace(/-/g, '');
|
||||||
|
return id || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function searchGoogle(
|
||||||
|
customerId: string,
|
||||||
|
accessToken: string,
|
||||||
|
query: string
|
||||||
|
): Promise<GaqlRow[]> {
|
||||||
|
const cid = customerId.replace(/-/g, '');
|
||||||
|
const attempts = [
|
||||||
|
gadsHeaders(accessToken),
|
||||||
|
gadsHeaders(accessToken, cid),
|
||||||
|
];
|
||||||
|
let lastErr = '';
|
||||||
|
for (const headers of attempts) {
|
||||||
|
const r = await fetch(`${GADS_API}/customers/${cid}/googleAds:search`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({ query }),
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
if (r.ok) {
|
||||||
|
const j = (await r.json()) as { results?: GaqlRow[] };
|
||||||
|
return j.results || [];
|
||||||
|
}
|
||||||
|
lastErr = await r.text();
|
||||||
|
}
|
||||||
|
throw new Error(`Google Ads ${cid}: ${lastErr.slice(0, 220)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ytThumb(youtubeId: string): string {
|
||||||
|
return `https://i.ytimg.com/vi/${youtubeId}/hqdefault.jpg`;
|
||||||
|
}
|
||||||
|
|
||||||
|
type AgBucket = {
|
||||||
|
adId: string;
|
||||||
|
adName: string;
|
||||||
|
campaignId: string;
|
||||||
|
campaignName: string;
|
||||||
|
status: string;
|
||||||
|
headlines: string[];
|
||||||
|
descriptions: string[];
|
||||||
|
images: string[];
|
||||||
|
youtubeIds: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live Google creatives: Performance Max asset groups + RSA/RDA ads.
|
||||||
|
* Metrics: prefer asset_group / ad metrics in range; else fall back to campaign ad_daily.
|
||||||
|
*/
|
||||||
|
export async function fetchGoogleCreativeCards(opts: {
|
||||||
|
tenantId: string;
|
||||||
|
dailyRows: Parameters<typeof aggregateDailyByAd>[0];
|
||||||
|
since: string;
|
||||||
|
until: string;
|
||||||
|
limit?: number;
|
||||||
|
}): Promise<{ ads: AdCreativeCard[]; warning?: string }> {
|
||||||
|
if (!googleAdsConfigured()) {
|
||||||
|
return {
|
||||||
|
ads: [],
|
||||||
|
warning: 'Google Ads API no configurada (GOOGLE_ADS_*)',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const customerId = await resolveGoogleCustomerId(opts.tenantId);
|
||||||
|
if (!customerId) {
|
||||||
|
return { ads: [], warning: '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ó',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateWhere = `segments.date BETWEEN '${opts.since}' AND '${opts.until}'`;
|
||||||
|
const limit = opts.limit ?? 24;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [agAssets, agMetrics, adRows, adMetrics] = await Promise.all([
|
||||||
|
searchGoogle(
|
||||||
|
customerId,
|
||||||
|
accessToken,
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
campaign.id,
|
||||||
|
campaign.name,
|
||||||
|
asset_group.id,
|
||||||
|
asset_group.name,
|
||||||
|
asset_group.status,
|
||||||
|
asset_group.final_urls,
|
||||||
|
asset_group_asset.field_type,
|
||||||
|
asset.id,
|
||||||
|
asset.type,
|
||||||
|
asset.text_asset.text,
|
||||||
|
asset.image_asset.full_size.url,
|
||||||
|
asset.youtube_video_asset.youtube_video_id,
|
||||||
|
asset.youtube_video_asset.youtube_video_title
|
||||||
|
FROM asset_group_asset
|
||||||
|
WHERE asset_group.status != 'REMOVED'
|
||||||
|
AND asset_group_asset.status != 'REMOVED'
|
||||||
|
`
|
||||||
|
),
|
||||||
|
searchGoogle(
|
||||||
|
customerId,
|
||||||
|
accessToken,
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
asset_group.id,
|
||||||
|
metrics.cost_micros,
|
||||||
|
metrics.impressions,
|
||||||
|
metrics.clicks,
|
||||||
|
metrics.conversions
|
||||||
|
FROM asset_group
|
||||||
|
WHERE ${dateWhere}
|
||||||
|
`
|
||||||
|
),
|
||||||
|
searchGoogle(
|
||||||
|
customerId,
|
||||||
|
accessToken,
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
campaign.id,
|
||||||
|
campaign.name,
|
||||||
|
ad_group.id,
|
||||||
|
ad_group.name,
|
||||||
|
ad_group_ad.status,
|
||||||
|
ad_group_ad.ad.id,
|
||||||
|
ad_group_ad.ad.name,
|
||||||
|
ad_group_ad.ad.type,
|
||||||
|
ad_group_ad.ad.final_urls,
|
||||||
|
ad_group_ad.ad.responsive_search_ad.headlines,
|
||||||
|
ad_group_ad.ad.responsive_search_ad.descriptions,
|
||||||
|
ad_group_ad.ad.responsive_display_ad.headlines,
|
||||||
|
ad_group_ad.ad.responsive_display_ad.long_headline,
|
||||||
|
ad_group_ad.ad.responsive_display_ad.descriptions,
|
||||||
|
ad_group_ad.ad.responsive_display_ad.business_name,
|
||||||
|
ad_group_ad.ad.image_ad.image_url,
|
||||||
|
ad_group_ad.ad.image_ad.name
|
||||||
|
FROM ad_group_ad
|
||||||
|
WHERE ad_group_ad.status != 'REMOVED'
|
||||||
|
AND campaign.status != 'REMOVED'
|
||||||
|
LIMIT 80
|
||||||
|
`
|
||||||
|
),
|
||||||
|
searchGoogle(
|
||||||
|
customerId,
|
||||||
|
accessToken,
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
ad_group_ad.ad.id,
|
||||||
|
metrics.cost_micros,
|
||||||
|
metrics.impressions,
|
||||||
|
metrics.clicks,
|
||||||
|
metrics.conversions
|
||||||
|
FROM ad_group_ad
|
||||||
|
WHERE ${dateWhere}
|
||||||
|
`
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const agMetricMap = new Map<
|
||||||
|
string,
|
||||||
|
{ spend: number; impressions: number; clicks: number; conversions: number }
|
||||||
|
>();
|
||||||
|
for (const row of agMetrics) {
|
||||||
|
const id = str(row.assetGroup?.id);
|
||||||
|
if (!id) continue;
|
||||||
|
const cur = agMetricMap.get(id) || {
|
||||||
|
spend: 0,
|
||||||
|
impressions: 0,
|
||||||
|
clicks: 0,
|
||||||
|
conversions: 0,
|
||||||
|
};
|
||||||
|
cur.spend += n(row.metrics?.costMicros) / 1_000_000;
|
||||||
|
cur.impressions += n(row.metrics?.impressions);
|
||||||
|
cur.clicks += n(row.metrics?.clicks);
|
||||||
|
cur.conversions += n(row.metrics?.conversions);
|
||||||
|
agMetricMap.set(id, cur);
|
||||||
|
}
|
||||||
|
|
||||||
|
const adMetricMap = new Map<
|
||||||
|
string,
|
||||||
|
{ spend: number; impressions: number; clicks: number; conversions: number }
|
||||||
|
>();
|
||||||
|
for (const row of adMetrics) {
|
||||||
|
const id = str(row.adGroupAd?.ad?.id);
|
||||||
|
if (!id) continue;
|
||||||
|
const cur = adMetricMap.get(id) || {
|
||||||
|
spend: 0,
|
||||||
|
impressions: 0,
|
||||||
|
clicks: 0,
|
||||||
|
conversions: 0,
|
||||||
|
};
|
||||||
|
cur.spend += n(row.metrics?.costMicros) / 1_000_000;
|
||||||
|
cur.impressions += n(row.metrics?.impressions);
|
||||||
|
cur.clicks += n(row.metrics?.clicks);
|
||||||
|
cur.conversions += n(row.metrics?.conversions);
|
||||||
|
adMetricMap.set(id, cur);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Campaign-level spend from ad_daily as fallback for PMax
|
||||||
|
const dailyAggs = aggregateDailyByAd(
|
||||||
|
opts.dailyRows,
|
||||||
|
'google',
|
||||||
|
opts.since,
|
||||||
|
opts.until
|
||||||
|
);
|
||||||
|
const campaignSpend = new Map<string, (typeof dailyAggs)[number]>();
|
||||||
|
for (const a of dailyAggs) {
|
||||||
|
campaignSpend.set(a.adId, a); // portal stores campaign id as ad_id
|
||||||
|
if (a.campaignName) campaignSpend.set(a.campaignName.toLowerCase(), a);
|
||||||
|
}
|
||||||
|
|
||||||
|
const agMap = new Map<string, AgBucket>();
|
||||||
|
for (const row of agAssets) {
|
||||||
|
const agid = str(row.assetGroup?.id);
|
||||||
|
if (!agid) continue;
|
||||||
|
let entry = agMap.get(agid);
|
||||||
|
if (!entry) {
|
||||||
|
entry = {
|
||||||
|
adId: `ag_${agid}`,
|
||||||
|
adName: str(row.assetGroup?.name) || `Asset group ${agid}`,
|
||||||
|
campaignId: str(row.campaign?.id),
|
||||||
|
campaignName: str(row.campaign?.name),
|
||||||
|
status: enumName(row.assetGroup?.status) || 'ENABLED',
|
||||||
|
headlines: [],
|
||||||
|
descriptions: [],
|
||||||
|
images: [],
|
||||||
|
youtubeIds: [],
|
||||||
|
};
|
||||||
|
agMap.set(agid, entry);
|
||||||
|
}
|
||||||
|
const ft = enumName(row.assetGroupAsset?.fieldType).toUpperCase();
|
||||||
|
const text = str(row.asset?.textAsset?.text);
|
||||||
|
const img = str(row.asset?.imageAsset?.fullSize?.url);
|
||||||
|
const yt = str(row.asset?.youtubeVideoAsset?.youtubeVideoId);
|
||||||
|
if (ft === 'HEADLINE' && text) entry.headlines.push(text);
|
||||||
|
else if (ft === 'LONG_HEADLINE' && text) entry.headlines.push(text);
|
||||||
|
else if (ft === 'DESCRIPTION' && text) entry.descriptions.push(text);
|
||||||
|
else if (
|
||||||
|
(ft.includes('MARKETING_IMAGE') || ft === 'LOGO' || ft === 'LANDSCAPE_LOGO') &&
|
||||||
|
img
|
||||||
|
) {
|
||||||
|
if (!entry.images.includes(img)) entry.images.push(img);
|
||||||
|
} else if (ft === 'YOUTUBE_VIDEO' && yt) {
|
||||||
|
if (!entry.youtubeIds.includes(yt)) entry.youtubeIds.push(yt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cards: AdCreativeCard[] = [];
|
||||||
|
|
||||||
|
for (const [agid, entry] of agMap) {
|
||||||
|
const m = agMetricMap.get(agid);
|
||||||
|
const camp =
|
||||||
|
campaignSpend.get(entry.campaignId) ||
|
||||||
|
campaignSpend.get(entry.campaignName.toLowerCase());
|
||||||
|
const spend = m?.spend ?? camp?.spend ?? 0;
|
||||||
|
const impressions = m?.impressions ?? camp?.impressions ?? 0;
|
||||||
|
const clicks = m?.clicks ?? camp?.clicks ?? 0;
|
||||||
|
const conversions = m?.conversions ?? camp?.conversions ?? 0;
|
||||||
|
// Skip zero-activity asset groups when we have spend elsewhere, keep if any spend
|
||||||
|
if (spend <= 0 && impressions <= 0 && clicks <= 0 && !m) continue;
|
||||||
|
|
||||||
|
const yt = entry.youtubeIds[0] || '';
|
||||||
|
const imageUrl = entry.images[0] || (yt ? ytThumb(yt) : '');
|
||||||
|
cards.push({
|
||||||
|
platform: 'google',
|
||||||
|
adId: entry.adId,
|
||||||
|
adName: entry.adName,
|
||||||
|
campaignName: entry.campaignName,
|
||||||
|
adsetName: 'Performance Max',
|
||||||
|
spend: Math.round(spend * 100) / 100,
|
||||||
|
impressions,
|
||||||
|
clicks,
|
||||||
|
conversions: Math.round(conversions * 100) / 100,
|
||||||
|
cpl: conversions > 0 ? spend / conversions : null,
|
||||||
|
mediaType: yt && !entry.images[0] ? 'video' : entry.images[0] ? 'pmax' : 'pmax',
|
||||||
|
thumbnailUrl: imageUrl,
|
||||||
|
imageUrl,
|
||||||
|
headline: [...new Set(entry.headlines)].slice(0, 5).join('\n'),
|
||||||
|
primaryText: [...new Set(entry.descriptions)].slice(0, 3).join('\n'),
|
||||||
|
status: entry.status,
|
||||||
|
youtubeId: yt || undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const row of adRows) {
|
||||||
|
const ad = row.adGroupAd?.ad;
|
||||||
|
const adId = str(ad?.id);
|
||||||
|
if (!adId) continue;
|
||||||
|
const m = adMetricMap.get(adId);
|
||||||
|
// Prefer ads with activity in range; keep a few without if needed
|
||||||
|
const spend = m?.spend ?? 0;
|
||||||
|
const impressions = m?.impressions ?? 0;
|
||||||
|
const clicks = m?.clicks ?? 0;
|
||||||
|
const conversions = m?.conversions ?? 0;
|
||||||
|
if (!m && cards.length >= limit) continue;
|
||||||
|
|
||||||
|
const rsa = ad?.responsiveSearchAd;
|
||||||
|
const rda = ad?.responsiveDisplayAd;
|
||||||
|
const headlines = [
|
||||||
|
...(rsa?.headlines || []).map((h) => str(h.text)),
|
||||||
|
...(rda?.headlines || []).map((h) => str(h.text)),
|
||||||
|
str(rda?.longHeadline?.text),
|
||||||
|
].filter(Boolean);
|
||||||
|
const descriptions = [
|
||||||
|
...(rsa?.descriptions || []).map((d) => str(d.text)),
|
||||||
|
...(rda?.descriptions || []).map((d) => str(d.text)),
|
||||||
|
].filter(Boolean);
|
||||||
|
const imageUrl = str(ad?.imageAd?.imageUrl);
|
||||||
|
const adType = enumName(ad?.type).toLowerCase() || 'rsa';
|
||||||
|
const mediaType = rsa
|
||||||
|
? 'rsa'
|
||||||
|
: rda
|
||||||
|
? 'rda'
|
||||||
|
: imageUrl
|
||||||
|
? 'image'
|
||||||
|
: adType;
|
||||||
|
|
||||||
|
cards.push({
|
||||||
|
platform: 'google',
|
||||||
|
adId,
|
||||||
|
adName: str(ad?.name) || headlines[0] || `Ad ${adId}`,
|
||||||
|
campaignName: str(row.campaign?.name),
|
||||||
|
adsetName: str(row.adGroup?.name),
|
||||||
|
spend: Math.round(spend * 100) / 100,
|
||||||
|
impressions,
|
||||||
|
clicks,
|
||||||
|
conversions: Math.round(conversions * 100) / 100,
|
||||||
|
cpl: conversions > 0 ? spend / conversions : null,
|
||||||
|
mediaType,
|
||||||
|
thumbnailUrl: imageUrl,
|
||||||
|
imageUrl,
|
||||||
|
headline: [...new Set(headlines)].slice(0, 5).join('\n'),
|
||||||
|
primaryText: [...new Set(descriptions)].slice(0, 3).join('\n'),
|
||||||
|
status: enumName(row.adGroupAd?.status),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
cards.sort((a, b) => b.spend - a.spend);
|
||||||
|
const sliced = cards.slice(0, limit);
|
||||||
|
|
||||||
|
if (!sliced.length) {
|
||||||
|
// Fallback: campaign cards from ad_daily (text only)
|
||||||
|
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',
|
||||||
|
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 { ads: sliced };
|
||||||
|
} catch (e) {
|
||||||
|
return {
|
||||||
|
ads: [],
|
||||||
|
warning: e instanceof Error ? e.message : 'Error Google creatives',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,6 +36,7 @@ export type AdCreativeCard = {
|
|||||||
headline: string;
|
headline: string;
|
||||||
primaryText: string;
|
primaryText: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
|
youtubeId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type DailyAgg = {
|
type DailyAgg = {
|
||||||
@@ -193,8 +194,9 @@ async function fetchMetaCreativeBundle(
|
|||||||
}, token);
|
}, token);
|
||||||
|
|
||||||
let creative = (raw.creative || {}) as Record<string, unknown>;
|
let creative = (raw.creative || {}) as Record<string, unknown>;
|
||||||
|
// Only hydrate creative detail when thumbnail/image missing
|
||||||
const cid = str(creative.id);
|
const cid = str(creative.id);
|
||||||
if (cid) {
|
if (cid && !str(creative.thumbnail_url) && !str(creative.image_url)) {
|
||||||
try {
|
try {
|
||||||
const detail = await graphGet<Record<string, unknown>>(
|
const detail = await graphGet<Record<string, unknown>>(
|
||||||
cid,
|
cid,
|
||||||
@@ -248,16 +250,16 @@ export async function fetchMetaCreativeCards(opts: {
|
|||||||
'meta',
|
'meta',
|
||||||
opts.since,
|
opts.since,
|
||||||
opts.until
|
opts.until
|
||||||
).slice(0, opts.limit ?? 40);
|
).slice(0, opts.limit ?? 16);
|
||||||
|
|
||||||
if (!aggs.length) {
|
if (!aggs.length) {
|
||||||
return { ads: [], warning: 'Sin ads Meta en ad_daily para este periodo' };
|
return { ads: [], warning: 'Sin ads Meta en ad_daily para este periodo' };
|
||||||
}
|
}
|
||||||
|
|
||||||
const out: AdCreativeCard[] = [];
|
const out: AdCreativeCard[] = [];
|
||||||
// Sequential batches of 5 to respect rate limits
|
// Parallel batches of 8 — single Graph call per ad (thumbnail already in creative{})
|
||||||
for (let i = 0; i < aggs.length; i += 5) {
|
for (let i = 0; i < aggs.length; i += 8) {
|
||||||
const chunk = aggs.slice(i, i + 5);
|
const chunk = aggs.slice(i, i + 8);
|
||||||
const bundles = await Promise.all(
|
const bundles = await Promise.all(
|
||||||
chunk.map((a) => fetchMetaCreativeBundle(a.adId, token))
|
chunk.map((a) => fetchMetaCreativeBundle(a.adId, token))
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user