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:
@@ -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,24 +155,37 @@ 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') {
|
||||||
const google = await fetchGoogleCreativeCards({
|
try {
|
||||||
tenantId: ctx.tenantId,
|
const google = await fetchGoogleCreativeCards({
|
||||||
dailyRows: daily,
|
tenantId: ctx.tenantId,
|
||||||
since,
|
dailyRows: daily,
|
||||||
until,
|
since,
|
||||||
limit: 24,
|
until,
|
||||||
});
|
limit: 24,
|
||||||
googleAds = google.ads;
|
});
|
||||||
if (google.warning) warnings.push(google.warning);
|
googleAds = google.ads.length
|
||||||
|
? 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,
|
||||||
|
|||||||
@@ -552,39 +552,70 @@ export default function AdTrackerClient({
|
|||||||
async function load() {
|
async function load() {
|
||||||
setCreativesLoading(true);
|
setCreativesLoading(true);
|
||||||
setCreativesWarning(null);
|
setCreativesWarning(null);
|
||||||
|
const platform = tab === 'meta' ? 'meta' : 'google';
|
||||||
|
const baseQs = {
|
||||||
|
since: range.since,
|
||||||
|
until: range.until,
|
||||||
|
platform,
|
||||||
|
};
|
||||||
|
|
||||||
|
let hasFast = false;
|
||||||
|
|
||||||
|
// 1) Warehouse first — instant metrics (source of truth from sync)
|
||||||
try {
|
try {
|
||||||
const platform = tab === 'meta' ? 'meta' : 'google';
|
const qs = new URLSearchParams({ ...baseQs, mode: 'fast' });
|
||||||
const qs = new URLSearchParams({
|
const r = await fetch(`/api/ad-tracker/creatives?${qs}`, {
|
||||||
since: range.since,
|
cache: 'no-store',
|
||||||
until: range.until,
|
|
||||||
platform,
|
|
||||||
});
|
});
|
||||||
|
const data = await r.json().catch(() => ({}));
|
||||||
|
if (cancelled) return;
|
||||||
|
if (r.ok) {
|
||||||
|
hasFast = true;
|
||||||
|
setCreatives((data.ads as AdCreativeCard[]) || []);
|
||||||
|
if (
|
||||||
|
data.metaClickSplit &&
|
||||||
|
typeof data.metaClickSplit.clicks === 'number'
|
||||||
|
) {
|
||||||
|
setMetaClickSplit(data.metaClickSplit);
|
||||||
|
} else if (platform === 'google') {
|
||||||
|
setMetaClickSplit(null);
|
||||||
|
}
|
||||||
|
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}`, {
|
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([]);
|
if (!hasFast) {
|
||||||
setMetaClickSplit(null);
|
setCreativesWarning(
|
||||||
setCreativesWarning(data.hint || data.error || 'No se pudieron cargar creatividades');
|
data.hint || data.error || 'No se pudieron cargar creatividades'
|
||||||
|
);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setCreatives((data.ads as AdCreativeCard[]) || []);
|
const liveAds = (data.ads as AdCreativeCard[]) || [];
|
||||||
|
if (liveAds.length) setCreatives(liveAds);
|
||||||
if (
|
if (
|
||||||
data.metaClickSplit &&
|
data.metaClickSplit &&
|
||||||
typeof data.metaClickSplit.clicks === 'number'
|
typeof data.metaClickSplit.clicks === 'number'
|
||||||
) {
|
) {
|
||||||
setMetaClickSplit(data.metaClickSplit);
|
setMetaClickSplit(data.metaClickSplit);
|
||||||
} else if (platform === 'google') {
|
|
||||||
setMetaClickSplit(null);
|
|
||||||
}
|
}
|
||||||
const w = Array.isArray(data.warnings) ? data.warnings.filter(Boolean) : [];
|
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 {
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
<p className="text-xs text-emerald-700">
|
<div className="space-y-1">
|
||||||
Conectado{email ? ` · ${email}` : ''}
|
<p className="text-xs text-emerald-700">
|
||||||
</p>
|
Conectado{email ? ` · ${email}` : ''}
|
||||||
|
{hasAds ? ' · Ads OK' : ''}
|
||||||
|
</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>}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user