Align Ads and Métricas on the same paginated ad_daily warehouse.
Ad Tracker only loaded 2000 newest rows, so Brava showed 0 € while Métricas summed the full month. Both now page the same table for the selected range. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
46
src/app/api/ads/daily/route.ts
Normal file
46
src/app/api/ads/daily/route.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { createClient } from '@/lib/supabase/server';
|
||||||
|
import { resolveActiveTenant } from '@/lib/tenant-context';
|
||||||
|
import { isAgencyRole, userRole } from '@/lib/agency';
|
||||||
|
import { fetchAdDailyPaged } from '@/lib/fetch-ad-daily';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
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 ctx = await resolveActiveTenant(user);
|
||||||
|
if (!ctx.tenantId || ctx.portfolioWide) {
|
||||||
|
return NextResponse.json({
|
||||||
|
rows: [],
|
||||||
|
warning: 'Selecciona un cliente en Contexto',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const since = req.nextUrl.searchParams.get('since') || '';
|
||||||
|
const until = req.nextUrl.searchParams.get('until') || '';
|
||||||
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(since) || !/^\d{4}-\d{2}-\d{2}$/.test(until)) {
|
||||||
|
return NextResponse.json({ error: 'since y until (YYYY-MM-DD)' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const agency = isAgencyRole(userRole(user));
|
||||||
|
const rows = await fetchAdDailyPaged({
|
||||||
|
tenantId: ctx.tenantId,
|
||||||
|
useServiceRole: agency && ctx.viaView,
|
||||||
|
since,
|
||||||
|
until,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
rows,
|
||||||
|
since,
|
||||||
|
until,
|
||||||
|
tenant_label: ctx.label,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { createClient } from '@/lib/supabase/server';
|
import { createClient } from '@/lib/supabase/server';
|
||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
import { resolveActiveTenant, restGet } from '@/lib/tenant-context';
|
import { resolveActiveTenant } from '@/lib/tenant-context';
|
||||||
import { isAgencyRole, userRole } from '@/lib/agency';
|
import { isAgencyRole, userRole } from '@/lib/agency';
|
||||||
import { AdDailyRow } from '@/lib/metrics';
|
import { AdDailyRow } from '@/lib/metrics';
|
||||||
import AdTrackerClient from '@/components/AdTrackerClient';
|
import AdTrackerClient from '@/components/AdTrackerClient';
|
||||||
|
import { fetchAdDailyPaged, adDailyLookbackSince } from '@/lib/fetch-ad-daily';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
@@ -41,19 +42,12 @@ export default async function AdTrackerPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let daily: AdDailyRow[] = [];
|
let daily: AdDailyRow[] = [];
|
||||||
if (agency && ctx.viaView && ctx.tenantId) {
|
if (ctx.tenantId) {
|
||||||
const tid = encodeURIComponent(ctx.tenantId);
|
daily = await fetchAdDailyPaged({
|
||||||
const d = await restGet<AdDailyRow[]>(
|
tenantId: ctx.tenantId,
|
||||||
`/rest/v1/ad_daily?tenant_id=eq.${tid}&select=*&order=date.desc&limit=2000`
|
useServiceRole: agency && ctx.viaView,
|
||||||
);
|
since: adDailyLookbackSince(),
|
||||||
daily = d.data || [];
|
});
|
||||||
} else {
|
|
||||||
const { data } = await supabase
|
|
||||||
.from('ad_daily')
|
|
||||||
.select('*')
|
|
||||||
.order('date', { ascending: false })
|
|
||||||
.limit(2000);
|
|
||||||
daily = (data as AdDailyRow[]) || [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -18,59 +18,13 @@ import {
|
|||||||
} from '@/lib/metrics';
|
} from '@/lib/metrics';
|
||||||
import { isAgencyRole, serviceHeaders, supabaseUrl, userRole } from '@/lib/agency';
|
import { isAgencyRole, serviceHeaders, supabaseUrl, userRole } from '@/lib/agency';
|
||||||
import { OrganicDailyRow } from '@/lib/organic';
|
import { OrganicDailyRow } from '@/lib/organic';
|
||||||
|
import {
|
||||||
|
adDailyLookbackSince,
|
||||||
|
fetchAdDailyPaged,
|
||||||
|
} from '@/lib/fetch-ad-daily';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
/** Enough history for YoY from Jan 2025 (inclusive). No artificial 700d floor. */
|
|
||||||
const AD_DAILY_LOOKBACK_DAYS =
|
|
||||||
Math.ceil((Date.now() - Date.UTC(2025, 0, 1)) / 86400000) + 14;
|
|
||||||
const AD_DAILY_PAGE = 1000;
|
|
||||||
|
|
||||||
async function fetchAdDaily(
|
|
||||||
tenantId: string | null,
|
|
||||||
useServiceRole: boolean
|
|
||||||
): Promise<AdDailyRow[]> {
|
|
||||||
const since = new Date();
|
|
||||||
since.setUTCDate(since.getUTCDate() - AD_DAILY_LOOKBACK_DAYS);
|
|
||||||
const sinceStr = since.toISOString().slice(0, 10);
|
|
||||||
const out: AdDailyRow[] = [];
|
|
||||||
|
|
||||||
if (useServiceRole) {
|
|
||||||
const headers = serviceHeaders();
|
|
||||||
const base = supabaseUrl();
|
|
||||||
if (!headers || !base) return [];
|
|
||||||
const tidFilter = tenantId
|
|
||||||
? `&tenant_id=eq.${encodeURIComponent(tenantId)}`
|
|
||||||
: '';
|
|
||||||
for (let offset = 0; offset < 200000; offset += AD_DAILY_PAGE) {
|
|
||||||
const r = await fetch(
|
|
||||||
`${base}/rest/v1/ad_daily?select=*&date=gte.${sinceStr}${tidFilter}&order=date.desc&limit=${AD_DAILY_PAGE}&offset=${offset}`,
|
|
||||||
{ headers, cache: 'no-store' }
|
|
||||||
);
|
|
||||||
if (!r.ok) break;
|
|
||||||
const chunk = (await r.json()) as AdDailyRow[];
|
|
||||||
out.push(...chunk);
|
|
||||||
if (chunk.length < AD_DAILY_PAGE) break;
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
const supabase = await createClient();
|
|
||||||
for (let offset = 0; offset < 200000; offset += AD_DAILY_PAGE) {
|
|
||||||
const { data, error } = await supabase
|
|
||||||
.from('ad_daily')
|
|
||||||
.select('*')
|
|
||||||
.gte('date', sinceStr)
|
|
||||||
.order('date', { ascending: false })
|
|
||||||
.range(offset, offset + AD_DAILY_PAGE - 1);
|
|
||||||
if (error) break;
|
|
||||||
const chunk = (data as AdDailyRow[]) || [];
|
|
||||||
out.push(...chunk);
|
|
||||||
if (chunk.length < AD_DAILY_PAGE) break;
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Paginate CRM leads (source + created_at + status) for attribution + cohort. */
|
/** Paginate CRM leads (source + created_at + status) for attribution + cohort. */
|
||||||
async function fetchCrmLeads(
|
async function fetchCrmLeads(
|
||||||
tenantId: string | null,
|
tenantId: string | null,
|
||||||
@@ -344,7 +298,11 @@ export default async function KpisPage() {
|
|||||||
'/rest/v1/kpi_weekly?select=*&order=week.desc'
|
'/rest/v1/kpi_weekly?select=*&order=week.desc'
|
||||||
);
|
);
|
||||||
weekly = w.data || [];
|
weekly = w.data || [];
|
||||||
daily = await fetchAdDaily(null, true);
|
daily = await fetchAdDailyPaged({
|
||||||
|
tenantId: null,
|
||||||
|
useServiceRole: true,
|
||||||
|
since: adDailyLookbackSince(),
|
||||||
|
});
|
||||||
crmLeads = [];
|
crmLeads = [];
|
||||||
wonDeals = await fetchWonDeals(null, true);
|
wonDeals = await fetchWonDeals(null, true);
|
||||||
organic = await fetchOrganicDaily(null, true);
|
organic = await fetchOrganicDaily(null, true);
|
||||||
@@ -355,7 +313,11 @@ export default async function KpisPage() {
|
|||||||
`/rest/v1/kpi_weekly?tenant_id=eq.${tid}&select=*&order=week.desc`
|
`/rest/v1/kpi_weekly?tenant_id=eq.${tid}&select=*&order=week.desc`
|
||||||
);
|
);
|
||||||
weekly = w.data || [];
|
weekly = w.data || [];
|
||||||
daily = await fetchAdDaily(ctx.tenantId, true);
|
daily = await fetchAdDailyPaged({
|
||||||
|
tenantId: ctx.tenantId,
|
||||||
|
useServiceRole: true,
|
||||||
|
since: adDailyLookbackSince(),
|
||||||
|
});
|
||||||
crmLeads = await fetchCrmLeads(ctx.tenantId, true);
|
crmLeads = await fetchCrmLeads(ctx.tenantId, true);
|
||||||
wonDeals = await fetchWonDeals(ctx.tenantId, true);
|
wonDeals = await fetchWonDeals(ctx.tenantId, true);
|
||||||
organic = await fetchOrganicDaily(ctx.tenantId, true);
|
organic = await fetchOrganicDaily(ctx.tenantId, true);
|
||||||
@@ -367,7 +329,11 @@ export default async function KpisPage() {
|
|||||||
.select('*')
|
.select('*')
|
||||||
.order('week', { ascending: false });
|
.order('week', { ascending: false });
|
||||||
weekly = (w as KpiWeeklyRow[]) || [];
|
weekly = (w as KpiWeeklyRow[]) || [];
|
||||||
daily = await fetchAdDaily(ctx.tenantId, false);
|
daily = await fetchAdDailyPaged({
|
||||||
|
tenantId: ctx.tenantId,
|
||||||
|
useServiceRole: false,
|
||||||
|
since: adDailyLookbackSince(),
|
||||||
|
});
|
||||||
crmLeads = await fetchCrmLeads(ctx.tenantId, false);
|
crmLeads = await fetchCrmLeads(ctx.tenantId, false);
|
||||||
wonDeals = await fetchWonDeals(ctx.tenantId, false);
|
wonDeals = await fetchWonDeals(ctx.tenantId, false);
|
||||||
organic = await fetchOrganicDaily(ctx.tenantId, false);
|
organic = await fetchOrganicDaily(ctx.tenantId, false);
|
||||||
|
|||||||
@@ -68,7 +68,9 @@ function rates(input: Omit<PlatformKpis, 'ctr' | 'cpc' | 'cpl'>): PlatformKpis {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function convOf(r: AdDailyRow): number {
|
function convOf(r: AdDailyRow): number {
|
||||||
return Number(r.conversions ?? r.platform_leads ?? 0) || 0;
|
const x = r.platform_leads ?? r.conversions;
|
||||||
|
const n = Number(x);
|
||||||
|
return Number.isFinite(n) ? n : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
function campaignName(r: AdDailyRow): string {
|
function campaignName(r: AdDailyRow): string {
|
||||||
@@ -541,12 +543,44 @@ export default function AdTrackerClient({
|
|||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [zoomAd, setZoomAd] = useState<AdCreativeCard | null>(null);
|
const [zoomAd, setZoomAd] = useState<AdCreativeCard | null>(null);
|
||||||
|
|
||||||
|
const [rowsLive, setRowsLive] = useState<AdDailyRow[]>(rows);
|
||||||
|
const [rowsLoading, setRowsLoading] = useState(false);
|
||||||
|
|
||||||
const range = useMemo(() => ({ since, until, label: rangeLabel }), [
|
const range = useMemo(() => ({ since, until, label: rangeLabel }), [
|
||||||
since,
|
since,
|
||||||
until,
|
until,
|
||||||
rangeLabel,
|
rangeLabel,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setRowsLive(rows);
|
||||||
|
}, [rows]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
async function loadRange() {
|
||||||
|
setRowsLoading(true);
|
||||||
|
try {
|
||||||
|
const qs = new URLSearchParams({
|
||||||
|
since: range.since,
|
||||||
|
until: range.until,
|
||||||
|
});
|
||||||
|
const r = await fetch(`/api/ads/daily?${qs}`, { cache: 'no-store' });
|
||||||
|
const data = await r.json().catch(() => ({}));
|
||||||
|
if (cancelled) return;
|
||||||
|
if (r.ok && Array.isArray(data.rows)) {
|
||||||
|
setRowsLive(data.rows as AdDailyRow[]);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setRowsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void loadRange();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [range.since, range.until]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (tab === 'resumen') {
|
if (tab === 'resumen') {
|
||||||
setCreatives([]);
|
setCreatives([]);
|
||||||
@@ -636,8 +670,8 @@ export default function AdTrackerClient({
|
|||||||
}, [range.since, range.until, tab]);
|
}, [range.since, range.until, tab]);
|
||||||
|
|
||||||
const filtered = useMemo(
|
const filtered = useMemo(
|
||||||
() => rows.filter((r) => inRange(r.date, range.since, range.until)),
|
() => rowsLive.filter((r) => inRange(r.date, range.since, range.until)),
|
||||||
[rows, range.since, range.until]
|
[rowsLive, range.since, range.until]
|
||||||
);
|
);
|
||||||
|
|
||||||
const metaRows = filtered.filter((r) => normalizePlatform(r.platform) === 'meta');
|
const metaRows = filtered.filter((r) => normalizePlatform(r.platform) === 'meta');
|
||||||
@@ -694,7 +728,9 @@ export default function AdTrackerClient({
|
|||||||
<PageHeader
|
<PageHeader
|
||||||
eyebrow="Publicidad"
|
eyebrow="Publicidad"
|
||||||
title={tagline || 'Ads'}
|
title={tagline || 'Ads'}
|
||||||
description={`${label} · Meta + Google · ${rangeLabel}`}
|
description={`${label} · Meta + Google · ${rangeLabel}${
|
||||||
|
rowsLoading ? ' · actualizando…' : ''
|
||||||
|
}`}
|
||||||
actions={<SyncNowButton forceModule="ads" />}
|
actions={<SyncNowButton forceModule="ads" />}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -361,8 +361,10 @@ export default function MetricsDashboard({
|
|||||||
|
|
||||||
const periodTotals = useMemo(() => {
|
const periodTotals = useMemo(() => {
|
||||||
let base: MetricTotals;
|
let base: MetricTotals;
|
||||||
if (rangedDaily.length) {
|
if (dailyRows.length) {
|
||||||
base = metricTotalsFromDaily(rangedDaily);
|
base = rangedDaily.length
|
||||||
|
? metricTotalsFromDaily(rangedDaily)
|
||||||
|
: metricTotalsFromDaily([]);
|
||||||
} else {
|
} else {
|
||||||
const ranged = weeklyRows.filter((r) => {
|
const ranged = weeklyRows.filter((r) => {
|
||||||
if (!r.week) return false;
|
if (!r.week) return false;
|
||||||
@@ -372,7 +374,7 @@ export default function MetricsDashboard({
|
|||||||
base = ranged.length ? sumWeekly(ranged) : month;
|
base = ranged.length ? sumWeekly(ranged) : month;
|
||||||
}
|
}
|
||||||
return withCohortFromDeals(base, rangedDeals);
|
return withCohortFromDeals(base, rangedDeals);
|
||||||
}, [rangedDaily, rangedDeals, weeklyRows, since, until, month]);
|
}, [dailyRows.length, rangedDaily, rangedDeals, weeklyRows, since, until, month]);
|
||||||
|
|
||||||
const rangeDeltas = useMemo(
|
const rangeDeltas = useMemo(
|
||||||
() => buildRangeDeltas(dailyRows, wonDeals, since, until),
|
() => buildRangeDeltas(dailyRows, wonDeals, since, until),
|
||||||
|
|||||||
63
src/lib/fetch-ad-daily.ts
Normal file
63
src/lib/fetch-ad-daily.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { createClient } from '@/lib/supabase/server';
|
||||||
|
import { serviceHeaders, supabaseUrl } from '@/lib/agency';
|
||||||
|
import type { AdDailyRow } from '@/lib/metrics';
|
||||||
|
|
||||||
|
const PAGE = 1000;
|
||||||
|
const MAX_ROWS = 200000;
|
||||||
|
|
||||||
|
export async function fetchAdDailyPaged(opts: {
|
||||||
|
tenantId: string | null;
|
||||||
|
useServiceRole: boolean;
|
||||||
|
since: string;
|
||||||
|
until?: string;
|
||||||
|
}): Promise<AdDailyRow[]> {
|
||||||
|
const { tenantId, useServiceRole, since, until } = opts;
|
||||||
|
const out: AdDailyRow[] = [];
|
||||||
|
|
||||||
|
if (useServiceRole) {
|
||||||
|
const headers = serviceHeaders();
|
||||||
|
const base = supabaseUrl();
|
||||||
|
if (!headers || !base) return [];
|
||||||
|
const tid = tenantId
|
||||||
|
? `&tenant_id=eq.${encodeURIComponent(tenantId)}`
|
||||||
|
: '';
|
||||||
|
const untilF = until ? `&date=lte.${until}` : '';
|
||||||
|
for (let offset = 0; offset < MAX_ROWS; offset += PAGE) {
|
||||||
|
const r = await fetch(
|
||||||
|
`${base}/rest/v1/ad_daily?select=*&date=gte.${since}${untilF}${tid}&order=date.desc&limit=${PAGE}&offset=${offset}`,
|
||||||
|
{ headers, cache: 'no-store' }
|
||||||
|
);
|
||||||
|
if (!r.ok) break;
|
||||||
|
const chunk = (await r.json()) as AdDailyRow[];
|
||||||
|
out.push(...chunk);
|
||||||
|
if (chunk.length < PAGE) break;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
const supabase = await createClient();
|
||||||
|
for (let offset = 0; offset < MAX_ROWS; offset += PAGE) {
|
||||||
|
let q = supabase
|
||||||
|
.from('ad_daily')
|
||||||
|
.select('*')
|
||||||
|
.gte('date', since)
|
||||||
|
.order('date', { ascending: false })
|
||||||
|
.range(offset, offset + PAGE - 1);
|
||||||
|
if (until) q = q.lte('date', until);
|
||||||
|
const { data, error } = await q;
|
||||||
|
if (error) break;
|
||||||
|
const chunk = (data as AdDailyRow[]) || [];
|
||||||
|
out.push(...chunk);
|
||||||
|
if (chunk.length < PAGE) break;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function adDailyLookbackSince(): string {
|
||||||
|
const since = new Date();
|
||||||
|
since.setUTCDate(
|
||||||
|
since.getUTCDate() -
|
||||||
|
(Math.ceil((Date.now() - Date.UTC(2025, 0, 1)) / 86400000) + 14)
|
||||||
|
);
|
||||||
|
return since.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user