perf: creatividades warehouse-first + aviso OAuth Ads Production

Carga métricas desde ad_daily al instante (mode=fast) y enriquece previews en vivo; la UI de Agencia avisa si falta scope Ads o consent Testing.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
4Ventures
2026-09-29 09:35:45 +02:00
parent 939c4a3131
commit a75f3ae30c
5 changed files with 174 additions and 36 deletions

View File

@@ -2,19 +2,49 @@ import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server'; import { createClient } from '@/lib/supabase/server';
import { resolveActiveTenant, restGet } from '@/lib/tenant-context'; import { resolveActiveTenant, restGet } from '@/lib/tenant-context';
import { isAgencyRole, userRole } from '@/lib/agency'; import { isAgencyRole, userRole } from '@/lib/agency';
import { AdDailyRow } from '@/lib/metrics'; import { AdDailyRow, linkClicksOf } from '@/lib/metrics';
import { normalizePlatform } from '@/lib/ad-tracker-range';
import { import {
AdCreativeCard, AdCreativeCard,
buildMetaWarehouseCards,
fetchMetaAccountClickSplit, fetchMetaAccountClickSplit,
fetchMetaCreativeCards, fetchMetaCreativeCards,
} from '@/lib/ad-creatives-meta'; } from '@/lib/ad-creatives-meta';
import { fetchGoogleCreativeCards } from '@/lib/ad-creatives-google'; import {
buildGoogleCardsFromDaily,
fetchGoogleCreativeCards,
} from '@/lib/ad-creatives-google';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
function metaClickSplitFromDaily(
daily: AdDailyRow[],
since: string,
until: string
): { clicks: number; linkClicks: number; spend: number } {
let clicks = 0;
let linkClicks = 0;
let spend = 0;
for (const r of daily) {
if (normalizePlatform(r.platform) !== 'meta') continue;
const d = (r.date || '').slice(0, 10);
if (d < since || d > until) continue;
clicks += Number(r.clicks || 0) || 0;
linkClicks += linkClicksOf(r);
spend += Number(r.spend || 0) || 0;
}
return {
clicks,
linkClicks: linkClicks || clicks,
spend: Math.round(spend * 100) / 100,
};
}
/** /**
* GET /api/ad-tracker/creatives?since=YYYY-MM-DD&until=YYYY-MM-DD&platform=meta|google|all * GET /api/ad-tracker/creatives?since=&until=&platform=meta|google|all&mode=fast|live
* Returns ad cards with creative preview URLs (Meta live; Google text fallback). *
* mode=fast → warehouse only (instant, same metrics as Ads sync)
* mode=live → enrich with Meta/Google creative assets (slower)
*/ */
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
const supabase = await createClient(); const supabase = await createClient();
@@ -36,6 +66,7 @@ export async function GET(req: NextRequest) {
const since = req.nextUrl.searchParams.get('since') || ''; const since = req.nextUrl.searchParams.get('since') || '';
const until = req.nextUrl.searchParams.get('until') || ''; const until = req.nextUrl.searchParams.get('until') || '';
const platform = (req.nextUrl.searchParams.get('platform') || 'all').toLowerCase(); const platform = (req.nextUrl.searchParams.get('platform') || 'all').toLowerCase();
const mode = (req.nextUrl.searchParams.get('mode') || 'live').toLowerCase();
if (!/^\d{4}-\d{2}-\d{2}$/.test(since) || !/^\d{4}-\d{2}-\d{2}$/.test(until)) { if (!/^\d{4}-\d{2}-\d{2}$/.test(since) || !/^\d{4}-\d{2}-\d{2}$/.test(until)) {
return NextResponse.json( return NextResponse.json(
{ error: 'bad_range', hint: 'since/until YYYY-MM-DD' }, { error: 'bad_range', hint: 'since/until YYYY-MM-DD' },
@@ -73,6 +104,29 @@ export async function GET(req: NextRequest) {
spend: number; spend: number;
} | null = null; } | null = null;
// Fast path: warehouse only — correct spend/clicks/leads from sync, no Graph wait
if (mode === 'fast') {
if (platform === 'meta' || platform === 'all') {
metaAds = buildMetaWarehouseCards(daily, since, until, 16);
metaClickSplit = metaClickSplitFromDaily(daily, since, until);
}
if (platform === 'google' || platform === 'all') {
googleAds = buildGoogleCardsFromDaily(daily, since, until, 24);
}
return NextResponse.json({
ok: true,
mode: 'fast',
tenantId: ctx.tenantId,
since,
until,
ads: [...metaAds, ...googleAds],
metaCount: metaAds.length,
googleCount: googleAds.length,
metaClickSplit,
warnings,
});
}
if (platform === 'meta' || platform === 'all') { if (platform === 'meta' || platform === 'all') {
try { try {
const [meta, clickSplit] = await Promise.all([ const [meta, clickSplit] = await Promise.all([
@@ -89,9 +143,11 @@ export async function GET(req: NextRequest) {
until, until,
}).catch(() => null), }).catch(() => null),
]); ]);
metaAds = meta.ads; metaAds = meta.ads.length
metaClickSplit = clickSplit; ? meta.ads
// Only keep hard configuration warnings (no Graph soft-fail noise) : buildMetaWarehouseCards(daily, since, until, 16);
metaClickSplit =
clickSplit || metaClickSplitFromDaily(daily, since, until);
if ( if (
meta.warning && meta.warning &&
/sin token meta|sin cuenta meta|sin actividad meta/i.test(meta.warning) /sin token meta|sin cuenta meta|sin actividad meta/i.test(meta.warning)
@@ -99,11 +155,13 @@ export async function GET(req: NextRequest) {
warnings.push(meta.warning); warnings.push(meta.warning);
} }
} catch { } catch {
metaAds = []; metaAds = buildMetaWarehouseCards(daily, since, until, 16);
metaClickSplit = metaClickSplitFromDaily(daily, since, until);
} }
} }
if (platform === 'google' || platform === 'all') { if (platform === 'google' || platform === 'all') {
try {
const google = await fetchGoogleCreativeCards({ const google = await fetchGoogleCreativeCards({
tenantId: ctx.tenantId, tenantId: ctx.tenantId,
dailyRows: daily, dailyRows: daily,
@@ -111,12 +169,23 @@ export async function GET(req: NextRequest) {
until, until,
limit: 24, limit: 24,
}); });
googleAds = google.ads; googleAds = google.ads.length
if (google.warning) warnings.push(google.warning); ? google.ads
: buildGoogleCardsFromDaily(daily, since, until, 24);
if (
google.warning &&
/caducado|oauth|no configurada|sin cuenta/i.test(google.warning)
) {
warnings.push(google.warning);
}
} catch {
googleAds = buildGoogleCardsFromDaily(daily, since, until, 24);
}
} }
return NextResponse.json({ return NextResponse.json({
ok: true, ok: true,
mode: 'live',
tenantId: ctx.tenantId, tenantId: ctx.tenantId,
since, since,
until, until,

View File

@@ -552,24 +552,25 @@ export default function AdTrackerClient({
async function load() { async function load() {
setCreativesLoading(true); setCreativesLoading(true);
setCreativesWarning(null); setCreativesWarning(null);
try {
const platform = tab === 'meta' ? 'meta' : 'google'; const platform = tab === 'meta' ? 'meta' : 'google';
const qs = new URLSearchParams({ const baseQs = {
since: range.since, since: range.since,
until: range.until, until: range.until,
platform, platform,
}); };
let hasFast = false;
// 1) Warehouse first — instant metrics (source of truth from sync)
try {
const qs = new URLSearchParams({ ...baseQs, mode: 'fast' });
const r = await fetch(`/api/ad-tracker/creatives?${qs}`, { const r = await fetch(`/api/ad-tracker/creatives?${qs}`, {
cache: 'no-store', cache: 'no-store',
}); });
const data = await r.json().catch(() => ({})); const data = await r.json().catch(() => ({}));
if (cancelled) return; if (cancelled) return;
if (!r.ok) { if (r.ok) {
setCreatives([]); hasFast = true;
setMetaClickSplit(null);
setCreativesWarning(data.hint || data.error || 'No se pudieron cargar creatividades');
return;
}
setCreatives((data.ads as AdCreativeCard[]) || []); setCreatives((data.ads as AdCreativeCard[]) || []);
if ( if (
data.metaClickSplit && data.metaClickSplit &&
@@ -579,12 +580,42 @@ export default function AdTrackerClient({
} else if (platform === 'google') { } else if (platform === 'google') {
setMetaClickSplit(null); setMetaClickSplit(null);
} }
const w = Array.isArray(data.warnings) ? data.warnings.filter(Boolean) : []; setCreativesLoading(false);
}
} catch {
/* continue to live */
}
// 2) Live enrich (previews) — never blocks empty UI
try {
const qs = new URLSearchParams({ ...baseQs, mode: 'live' });
const r = await fetch(`/api/ad-tracker/creatives?${qs}`, {
cache: 'no-store',
});
const data = await r.json().catch(() => ({}));
if (cancelled) return;
if (!r.ok) {
if (!hasFast) {
setCreativesWarning(
data.hint || data.error || 'No se pudieron cargar creatividades'
);
}
return;
}
const liveAds = (data.ads as AdCreativeCard[]) || [];
if (liveAds.length) setCreatives(liveAds);
if (
data.metaClickSplit &&
typeof data.metaClickSplit.clicks === 'number'
) {
setMetaClickSplit(data.metaClickSplit);
}
const w = Array.isArray(data.warnings)
? data.warnings.filter(Boolean)
: [];
setCreativesWarning(w.length ? w.join(' · ') : null); setCreativesWarning(w.length ? w.join(' · ') : null);
} catch { } catch {
if (!cancelled) { if (!cancelled && !hasFast) {
setCreatives([]);
setMetaClickSplit(null);
setCreativesWarning('Error al cargar creatividades'); setCreativesWarning('Error al cargar creatividades');
} }
} finally { } finally {

View File

@@ -18,6 +18,7 @@ export default function DriveConnectPanel() {
const [configured, setConfigured] = useState(false); const [configured, setConfigured] = useState(false);
const [connected, setConnected] = useState(false); const [connected, setConnected] = useState(false);
const [email, setEmail] = useState<string | null>(null); const [email, setEmail] = useState<string | null>(null);
const [hasAds, setHasAds] = useState(false);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [matching, setMatching] = useState(false); const [matching, setMatching] = useState(false);
const [matches, setMatches] = useState<MatchRow[] | null>(null); const [matches, setMatches] = useState<MatchRow[] | null>(null);
@@ -31,6 +32,7 @@ export default function DriveConnectPanel() {
setConfigured(Boolean(data.configured)); setConfigured(Boolean(data.configured));
setConnected(Boolean(data.connected)); setConnected(Boolean(data.connected));
setEmail(data.email || null); setEmail(data.email || null);
setHasAds(Boolean(data.hasAds));
setLoading(false); setLoading(false);
} }
@@ -145,12 +147,23 @@ export default function DriveConnectPanel() {
</div> </div>
{connected ? ( {connected ? (
<div className="space-y-1">
<p className="text-xs text-emerald-700"> <p className="text-xs text-emerald-700">
Conectado{email ? ` · ${email}` : ''} Conectado{email ? ` · ${email}` : ''}
{hasAds ? ' · Ads OK' : ''}
</p> </p>
{!hasAds ? (
<p className="text-xs text-amber-800 bg-amber-50 border border-amber-200 rounded-lg px-3 py-2">
Falta el scope de Google Ads. Pulsa{' '}
<strong>Reconectar Google</strong> y acepta Ads en el
consentimiento (consent screen en Production).
</p>
) : null}
</div>
) : ( ) : (
<p className="text-xs text-amber-800"> <p className="text-xs text-amber-800">
Aún no conectado — pulsa el botón naranja. Aún no conectado — pulsa el botón naranja con marketing@4ventures.es.
Los clientes no necesitan hacer login en Google.
</p> </p>
)} )}
{msg && <p className="text-xs text-gray-600">{msg}</p>} {msg && <p className="text-xs text-gray-600">{msg}</p>}

View File

@@ -217,6 +217,22 @@ function cardsFromDaily(
})); }));
} }
/** Warehouse-only Google cards (fast path). */
export function buildGoogleCardsFromDaily(
dailyRows: Parameters<typeof aggregateDailyByAd>[0],
since: string,
until: string,
limit = 24
): AdCreativeCard[] {
return cardsFromDaily(
dailyRows,
since,
until,
limit,
'Métricas desde warehouse (sync Ads)'
);
}
/** /**
* Live Google creatives: Performance Max asset groups + RSA/RDA ads. * Live Google creatives: Performance Max asset groups + RSA/RDA ads.
* Always falls back to ad_daily campaign cards when the API/token fails. * Always falls back to ad_daily campaign cards when the API/token fails.

View File

@@ -317,6 +317,15 @@ function cleanMetaAdName(name: string): string {
} }
/** Campaign-level cards from warehouse — always available, never throws. */ /** Campaign-level cards from warehouse — always available, never throws. */
export function buildMetaWarehouseCards(
dailyRows: Parameters<typeof aggregateDailyByAd>[0],
since: string,
until: string,
limit = 16
): AdCreativeCard[] {
return metaCardsFromDaily(dailyRows, since, until, limit);
}
function metaCardsFromDaily( function metaCardsFromDaily(
dailyRows: Parameters<typeof aggregateDailyByAd>[0], dailyRows: Parameters<typeof aggregateDailyByAd>[0],
since: string, since: string,