From a71ec99d1fba4d4bee00b5595b55d06776ecb155 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Tue, 1 Sep 2026 16:09:14 +0000 Subject: [PATCH] Stop the ad dashboard reporting a cancelled query as zero delivery /dashboard/ads read 0 for everything, intermittently, while the account was delivering 176,264 impressions over the window. The measure was right this time -- #199 and #225 both hold -- and the data was there. The RPCs were being cancelled. ad_account_series, ad_campaign_totals and the two daily-series functions are security invoker, so the RLS policy on ad_impressions ("slot is mine OR campaign is mine") joins the plan. With it the planner abandons the hash join for a nested loop: one index scan per owned campaign, 139 loops, ~176k random heap fetches, 401,791 buffers (~3GB) touched per page load. ad_impressions passed 364k rows / 154MB and traffic ran 10x baseline on 2026-09-01, which tipped it over the 8s statement_timeout on `authenticated` -- 34 cancellations in two hours, surfacing as HTTP 500 on three RPCs. Each function already did its own authorisation and never relied on RLS for it: every read is gated by `_id in (select id from owned)` where owned is `owner_id = auth.uid()`. Running them as definer drops the RLS subplans and the planner picks the hash join again: 11,818 buffers / 208ms against 401,791 / 932ms, byte-identical output. Verified with a stranger's JWT that all five still return 0 rows. Note the guard is `in` and not `not in`, so an anon caller gets an empty `owned` rather than everything. The second half is why this took a log dive to find. Every loader swallowed the error into a zero-filled result, so a cancelled query and a genuinely quiet range produced identical output and the page reported four confident zeros over a live network. The zero-fill stays -- one bad panel should not take the page down -- but the loaders now return Loaded carrying `failed`, log the error instead of discarding it, and the four ad surfaces render "couldn't load" in place of the zeros. The PDF report says so too: that document goes to accountants, where a silent zero is read as fact. Migration is already applied to prod. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01318XDMF7H8AtH7h4ZjweTS --- app/(app)/dashboard/ads/[id]/page.tsx | 2 +- app/(app)/dashboard/ads/earnings/page.tsx | 7 + app/(app)/dashboard/ads/page.tsx | 15 +- app/(app)/dashboard/ads/slots/page.tsx | 8 +- components/ads/stats-unavailable.tsx | 23 ++ lib/ads/earnings-data.ts | 22 +- lib/ads/earnings-report.ts | 8 + lib/ads/series.ts | 82 +++-- ...000_ad_reporting_rpcs_security_definer.sql | 287 ++++++++++++++++++ tests/ads-earnings-free-tier.test.ts | 13 +- tests/ads-stats-box.test.ts | 23 +- 11 files changed, 448 insertions(+), 42 deletions(-) create mode 100644 components/ads/stats-unavailable.tsx create mode 100644 supabase/migrations/20260901153000_ad_reporting_rpcs_security_definer.sql diff --git a/app/(app)/dashboard/ads/[id]/page.tsx b/app/(app)/dashboard/ads/[id]/page.tsx index 3f07c456..5ccd057d 100644 --- a/app/(app)/dashboard/ads/[id]/page.tsx +++ b/app/(app)/dashboard/ads/[id]/page.tsx @@ -83,7 +83,7 @@ export default async function CampaignDetailPage({ const clicks = (stats?.clicks as number) ?? 0; const freeImpressions = (stats?.free_impressions as number) ?? 0; const freeClicks = (stats?.free_clicks as number) ?? 0; - const daily = series.get(id) ?? []; + const daily = series.data.get(id) ?? []; const today = utcToday(); const creditsAvailable = (profile?.credits_balance ?? 0) + (profile?.ad_bonus_credits ?? 0); const display = campaignDisplayStatus(campaign, today, creditsAvailable); diff --git a/app/(app)/dashboard/ads/earnings/page.tsx b/app/(app)/dashboard/ads/earnings/page.tsx index aa576ca7..54f435d5 100644 --- a/app/(app)/dashboard/ads/earnings/page.tsx +++ b/app/(app)/dashboard/ads/earnings/page.tsx @@ -3,6 +3,7 @@ import dynamic from "next/dynamic"; import { createClient } from "@/lib/supabase/server"; import { loadEarnings, dollars, type EarningsModel } from "@/lib/ads/earnings-data"; import { EarningsPdfButton } from "@/components/ads/earnings-pdf-button"; +import { StatsUnavailable } from "@/components/ads/stats-unavailable"; // recharts is client-only; keep it out of the server bundle. const MoneyTrend = dynamic(() => import("@/components/ads/money-trend").then((m) => m.MoneyTrend)); @@ -17,6 +18,8 @@ function ctr(clicks: number, impressions: number): string { const EMPTY: EarningsModel = { rangeDays: RANGE, + // Signed out: nothing was attempted, so nothing failed. + statsUnavailable: false, totals: { spentCents: 0, earnedCents: 0, @@ -72,6 +75,10 @@ export default async function EarningsPage() { you spend as an advertiser. Download a PDF report for your accountant or team.

+ {model.statsUnavailable && ( + + )} + {/* Balances, not a period. "Available to withdraw" is lifetime earnings minus lifetime payouts; clipping it to the last {RANGE} days would under-report money the account is owed. The tables below are the ones diff --git a/app/(app)/dashboard/ads/page.tsx b/app/(app)/dashboard/ads/page.tsx index 61ccd709..b8864d8c 100644 --- a/app/(app)/dashboard/ads/page.tsx +++ b/app/(app)/dashboard/ads/page.tsx @@ -6,6 +6,7 @@ import { MiniTrend } from "@/components/ads/mini-trend"; import { AccountTrend } from "@/components/ads/account-trend"; import { RangeTabs } from "@/components/ads/range-tabs"; import { StatSpark } from "@/components/ads/stat-spark"; +import { StatsUnavailable } from "@/components/ads/stats-unavailable"; import { deliveredClicks, deliveredImpressions, @@ -66,6 +67,10 @@ export default async function AdsPage({ // Spendable credits decide whether a campaign is on the paid tier or running // as free backfill, so the badge can't be derived from the campaign row alone. let creditsAvailable: number | null = null; + // A failed stats query zero-fills, so without this the page would report a + // confident 0 for a range it simply could not read. See Loaded<> in + // lib/ads/series.ts. + let statsFailed = false; if (user) { const [{ data }, { data: profile }, accountSeries, campaignTotals] = await Promise.all([ supabase @@ -84,13 +89,15 @@ export default async function AdsPage({ ]); creditsAvailable = (profile?.credits_balance ?? 0) + (profile?.ad_bonus_credits ?? 0); campaigns = (data as CampaignRow[]) ?? []; - series = accountSeries; - rangeById = campaignTotals; - seriesById = await getCampaignDailySeries( + series = accountSeries.data; + rangeById = campaignTotals.data; + const daily = await getCampaignDailySeries( supabase, campaigns.map((c) => c.id), 30, ); + seriesById = daily.data; + statsFailed = accountSeries.failed || campaignTotals.failed || daily.failed; } const today = utcToday(); @@ -128,6 +135,8 @@ export default async function AdsPage({ {range.hint} + {statsFailed && } + {/* Delivery first, revenue second. The tiles count every ad actually shown — paid inventory plus free backfill — and name the split underneath, so a range in which nothing was billable reports the diff --git a/app/(app)/dashboard/ads/slots/page.tsx b/app/(app)/dashboard/ads/slots/page.tsx index 2b1ff73c..b7cef397 100644 --- a/app/(app)/dashboard/ads/slots/page.tsx +++ b/app/(app)/dashboard/ads/slots/page.tsx @@ -3,6 +3,7 @@ import { createClient } from "@/lib/supabase/server"; import { env } from "@/lib/env"; import { fetchSupportedTokens } from "@/lib/coinpay-tokens"; import { SlotManager } from "@/components/ads/slot-manager"; +import { StatsUnavailable } from "@/components/ads/stats-unavailable"; import { deliveredClicks, deliveredImpressions, @@ -53,6 +54,8 @@ export default async function SlotsPage() { const withdrawnBySlot = new Map(); const payoutsBySlot = new Map(); let statsBySlot = new Map(); + // Distinguishes "no earnings yet" from "could not read them". + let statsFailed = false; if (user) { const [{ data: p }, { data: s }, { data: ledger }, { data: payouts }, slotStats] = await Promise.all([ @@ -77,7 +80,8 @@ export default async function SlotsPage() { ]); projects = (p as Project[]) ?? []; slots = (s as Slot[]) ?? []; - statsBySlot = slotStats; + statsBySlot = slotStats.data; + statsFailed = slotStats.failed; for (const row of (ledger as { slot_id: string; amount_cents: number }[]) ?? []) { if (row.slot_id) earnedBySlot.set(row.slot_id, (earnedBySlot.get(row.slot_id) ?? 0) + (row.amount_cents ?? 0)); } @@ -124,6 +128,8 @@ export default async function SlotsPage() { for the clicks.

+ {statsFailed && } + {projects.length === 0 ? (
No sites yet — a site is a CrawlProof project.{" "} diff --git a/components/ads/stats-unavailable.tsx b/components/ads/stats-unavailable.tsx new file mode 100644 index 00000000..a8c1688f --- /dev/null +++ b/components/ads/stats-unavailable.tsx @@ -0,0 +1,23 @@ +/** + * Shown when a stats query failed, in place of the zeros it would otherwise + * have rendered. + * + * The ad dashboards zero-fill on failure so one bad panel cannot take the page + * down. Without this banner that choice is indistinguishable from a real run of + * no delivery, which is how a live network reporting six figures of impressions + * came to show four zeros and read as a dead pipeline. + */ +export function StatsUnavailable({ what = "these figures" }: { what?: string }) { + return ( +

+ + Couldn't load {what}. + {" "} + The figures below are not zero — they are missing. This is usually a query + that took too long; reloading often fixes it. +

+ ); +} diff --git a/lib/ads/earnings-data.ts b/lib/ads/earnings-data.ts index ebea2502..486d3370 100644 --- a/lib/ads/earnings-data.ts +++ b/lib/ads/earnings-data.ts @@ -47,6 +47,15 @@ export type EarningsPayoutRow = { export type EarningsModel = { rangeDays: number; + /** + * True when a delivery query failed and its figures were zero-filled. + * + * Worth carrying all the way into the model because this feeds the PDF report + * as well as the page: without it a cancelled query ships as a document + * stating the account delivered nothing, which is a stronger claim than + * anything we actually know. + */ + statsUnavailable: boolean; /** * Money is a balance and delivery is a rate, so they answer different * questions and cannot share a window. @@ -157,7 +166,7 @@ export async function loadEarnings( // campaigns dashboard reports. A free-tier impression is still an impression; // what it is not is revenue, and the money columns say so on their own. const campaignRows: EarningsCampaignRow[] = campaigns.map((c) => { - const s = campaignTotals.get(c.id) ?? EMPTY_TOTALS; + const s = campaignTotals.data.get(c.id) ?? EMPTY_TOTALS; return { id: c.id, name: c.name, @@ -169,7 +178,7 @@ export async function loadEarnings( }); const slotRows: EarningsSlotRow[] = slots.map((sl) => { - const s = slotTotals.get(sl.id) ?? EMPTY_SLOT_TOTALS; + const s = slotTotals.data.get(sl.id) ?? EMPTY_SLOT_TOTALS; return { id: sl.id, name: projectsById.get(sl.project_id)?.name ?? "Site", @@ -199,11 +208,16 @@ export async function loadEarnings( getCampaignDailySeries(supabase, campaignRows.map((c) => c.id), days), getSlotDailySeries(supabase, slotRows.map((s) => s.id), days), ]); - const daily = mergeMoneySeries(campaignSeries, slotSeries, days); + const daily = mergeMoneySeries(campaignSeries.data, slotSeries.data, days); const earnedTodayCents = daily.length ? daily[daily.length - 1].earnedCents : 0; return { rangeDays: days, + statsUnavailable: + campaignTotals.failed || + slotTotals.failed || + campaignSeries.failed || + slotSeries.failed, totals: { spentCents, earnedCents, @@ -217,7 +231,7 @@ export async function loadEarnings( pubImpressions: slotRows.reduce((a, s) => a + s.impressions, 0), pubClicks: slotRows.reduce((a, s) => a + s.clicks, 0), invalidClicks: slots.reduce( - (a, sl) => a + (slotTotals.get(sl.id)?.invalidClicks ?? 0), + (a, sl) => a + (slotTotals.data.get(sl.id)?.invalidClicks ?? 0), 0, ), }, diff --git a/lib/ads/earnings-report.ts b/lib/ads/earnings-report.ts index 19736897..f4e5598e 100644 --- a/lib/ads/earnings-report.ts +++ b/lib/ads/earnings-report.ts @@ -127,6 +127,14 @@ export function buildEarningsReportHtml(input: {
Account: ${esc(account)} · Period: ${esc(from)} → ${esc(to)} (${model.rangeDays} days) · Generated ${esc( gen.toLocaleString(), )}
+ ${ + // A report that says "0 impressions" because a query was cancelled is + // worse than one that admits it could not read them: this document gets + // handed to accountants and teams, where a silent zero is taken as fact. + model.statsUnavailable + ? `
Delivery figures for this period could not be loaded and are shown as zero. Regenerate this report before relying on the impression and click columns.
` + : "" + }
Total earned
${esc(dollars(t.earnedCents))}
diff --git a/lib/ads/series.ts b/lib/ads/series.ts index 38fb3380..30a460ef 100644 --- a/lib/ads/series.ts +++ b/lib/ads/series.ts @@ -27,6 +27,36 @@ export const EMPTY_TOTALS: RangeTotals = { spentCents: 0, }; +/** + * A loader's result together with whether the query behind it actually ran. + * + * Every loader below zero-fills rather than throwing, so one failing panel + * cannot take the whole page down with it. That part is deliberate and stays. + * What it cost is a way to tell the two apart: a cancelled query and a + * genuinely quiet range produced byte-identical output, so the dashboard could + * report four zeros over a network delivering six figures and say nothing was + * wrong. It has now done exactly that three times, for three unrelated reasons + * — the paid-only measure (#199), the same bug on two more surfaces (#225), and + * an RPC being cancelled by statement_timeout. Each time the zeros were read as + * a dead pipeline and diagnosed from scratch. + * + * `failed` is how a caller tells "we could not load this" from "this is 0". + */ +export type Loaded = { data: T; failed: boolean }; + +/** + * Record an RPC failure and report whether there was one. + * + * Logs, because the error was previously discarded at the point of failure: + * the only surviving evidence that a query had been cancelled was in Postgres' + * own logs, which is a long way to go to explain a tile reading 0. + */ +function rpcFailed(name: string, error: { message?: string } | null): boolean { + if (!error) return false; + console.error(`[ads] RPC ${name} failed: ${error.message ?? "unknown error"}`); + return true; +} + type AccountSeriesRow = { bucket: string; impressions: number | string; @@ -49,13 +79,14 @@ export async function getAccountSeries( supabase: SupabaseClient, range: RangeDef, now: Date = new Date(), -): Promise { +): Promise> { const { data, error } = await supabase.rpc("ad_account_series", { p_since: rangeSince(range, now), p_bucket_seconds: range.bucketSeconds, }); - const rows = error ? [] : ((data as AccountSeriesRow[]) ?? []); + const failed = rpcFailed("ad_account_series", error); + const rows = failed ? [] : ((data as AccountSeriesRow[]) ?? []); // "All time" has no fixed start, so the axis runs from the oldest bucket that // actually has data rather than from a window offset. @@ -86,7 +117,7 @@ export async function getAccountSeries( point.spentCents += Number(row.spent_cents) || 0; } - return [...byBucket.values()].sort((a, b) => a.t - b.t); + return { data: [...byBucket.values()].sort((a, b) => a.t - b.t), failed }; } function allTimeAxis(rows: AccountSeriesRow[], range: RangeDef, now: Date): number[] { @@ -168,7 +199,7 @@ export async function getCampaignRangeTotals( supabase: SupabaseClient, range: RangeDef, now: Date = new Date(), -): Promise> { +): Promise>> { return getCampaignTotalsSince(supabase, rangeSince(range, now)); } @@ -182,10 +213,10 @@ export async function getCampaignRangeTotals( export async function getCampaignTotalsSince( supabase: SupabaseClient, since: string | null, -): Promise> { +): Promise>> { const out = new Map(); const { data, error } = await supabase.rpc("ad_campaign_totals", { p_since: since }); - if (error) return out; + if (rpcFailed("ad_campaign_totals", error)) return { data: out, failed: true }; for (const row of (data as CampaignTotalsRow[]) ?? []) { out.set(row.campaign_id, { @@ -196,7 +227,7 @@ export async function getCampaignTotalsSince( spentCents: Number(row.spent_cents) || 0, }); } - return out; + return { data: out, failed: false }; } /** @@ -247,10 +278,10 @@ type SlotTotalsRow = { export async function getSlotTotalsSince( supabase: SupabaseClient, since: string | null, -): Promise> { +): Promise>> { const out = new Map(); const { data, error } = await supabase.rpc("ad_slot_totals", { p_since: since }); - if (error) return out; + if (rpcFailed("ad_slot_totals", error)) return { data: out, failed: true }; for (const row of (data as SlotTotalsRow[]) ?? []) { out.set(row.slot_id, { @@ -262,7 +293,7 @@ export async function getSlotTotalsSince( earnedCents: Number(row.earned_cents) || 0, }); } - return out; + return { data: out, failed: false }; } /** @@ -318,9 +349,10 @@ type DailySeriesRow = { /** * Per-campaign daily impressions / clicks / spend for the last `days`. * - * Aggregated server-side by the ad_campaign_daily_series RPC (security_invoker, - * so RLS scopes it to the caller's own campaigns). We must NOT fetch and bucket - * raw ad_impressions rows here: PostgREST caps a response at 1000 rows, so once + * Aggregated server-side by the ad_campaign_daily_series RPC (security definer, + * scoped by its own `owner_id = auth.uid()` filter rather than by RLS — see + * 20260901153000_ad_reporting_rpcs_security_definer.sql for why). We must NOT + * fetch and bucket raw ad_impressions rows here: PostgREST caps a response at 1000 rows, so once * total impressions in the window exceed 1000 a few high-volume campaigns eat * the whole page and every other campaign gets zero rows back — rendering * "no traffic yet" despite having recent impressions. The RPC returns at most @@ -331,13 +363,13 @@ export async function getCampaignDailySeries( supabase: SupabaseClient, campaignIds: string[], days = 30, -): Promise> { +): Promise>> { const axis = dayAxis(days); const result = new Map(); const emptyFor = () => axis.map((date) => ({ date, impressions: 0, clicks: 0, spentCents: 0 })); for (const id of campaignIds) result.set(id, emptyFor()); - if (campaignIds.length === 0) return result; + if (campaignIds.length === 0) return { data: result, failed: false }; // index[campaignId][date] -> point, for O(1) accumulation const index = new Map>(); @@ -350,8 +382,10 @@ export async function getCampaignDailySeries( const { data, error } = await supabase.rpc("ad_campaign_daily_series", { days, }); - // On error, fall back to the zero-filled series rather than throwing the page. - if (error) return result; + // On error, fall back to the zero-filled series rather than throwing the + // page — but say so, so the caller can render "couldn't load" over a flat + // line instead of presenting it as a real month of no traffic. + if (rpcFailed("ad_campaign_daily_series", error)) return { data: result, failed: true }; for (const row of (data as DailySeriesRow[]) ?? []) { const point = index.get(row.campaign_id)?.get(dayKey(row.day)); @@ -362,7 +396,7 @@ export async function getCampaignDailySeries( } } - return result; + return { data: result, failed: false }; } type SlotSeriesRow = { @@ -374,20 +408,20 @@ type SlotSeriesRow = { /** * Per-slot daily clicks / publisher earnings for the last `days`. - * Server-side aggregate via the ad_slot_daily_series RPC (security_invoker → - * RLS scopes to the caller's own slots). Same 1000-row-cap rationale as + * Server-side aggregate via the ad_slot_daily_series RPC (security definer, + * self-scoped by `owner_id = auth.uid()`). Same 1000-row-cap rationale as * getCampaignDailySeries. See migration 20260717150000_ad_slot_daily_series_rpc.sql. */ export async function getSlotDailySeries( supabase: SupabaseClient, slotIds: string[], days = 30, -): Promise> { +): Promise>> { const axis = dayAxis(days); const result = new Map(); const emptyFor = () => axis.map((date) => ({ date, clicks: 0, earnedCents: 0 })); for (const id of slotIds) result.set(id, emptyFor()); - if (slotIds.length === 0) return result; + if (slotIds.length === 0) return { data: result, failed: false }; const index = new Map>(); for (const id of slotIds) { @@ -397,7 +431,7 @@ export async function getSlotDailySeries( } const { data, error } = await supabase.rpc("ad_slot_daily_series", { days }); - if (error) return result; + if (rpcFailed("ad_slot_daily_series", error)) return { data: result, failed: true }; for (const row of (data as SlotSeriesRow[]) ?? []) { const point = index.get(row.slot_id)?.get(dayKey(row.day)); @@ -407,7 +441,7 @@ export async function getSlotDailySeries( } } - return result; + return { data: result, failed: false }; } /** Merge campaign spend series + slot earnings series into one account-wide diff --git a/supabase/migrations/20260901153000_ad_reporting_rpcs_security_definer.sql b/supabase/migrations/20260901153000_ad_reporting_rpcs_security_definer.sql new file mode 100644 index 00000000..ffa0c97d --- /dev/null +++ b/supabase/migrations/20260901153000_ad_reporting_rpcs_security_definer.sql @@ -0,0 +1,287 @@ +-- Ad reporting RPCs: run as definer so RLS stops forcing a nested loop. +-- +-- The advertiser dashboard read 0 for everything, intermittently, while the +-- account was delivering 176k impressions over the window. Not a measure bug +-- this time (that was #199 and #225): the RPCs were being CANCELLED. +-- +-- These functions are security invoker, so the RLS policy on ad_impressions +-- ("slot is mine OR campaign is mine") joins the plan. With it, the planner +-- abandons the hash join and picks a nested loop -- one index scan per owned +-- campaign, 139 loops, ~176k random heap fetches -- touching 401,791 buffers +-- (~3GB) per page load. Warm that is ~930ms; cold, against the 8s +-- statement_timeout on `authenticated`, it loses. ad_impressions passed 364k +-- rows / 154MB and traffic ran 10x baseline on 2026-09-01 (14,580/hr against +-- ~500/hr), which is what finally tipped it: 34 "canceling statement due to +-- statement timeout" errors in two hours, surfacing as HTTP 500 on +-- ad_account_series, ad_campaign_totals and ad_campaign_daily_series. +-- +-- Every caller swallows the error into an empty result (`error ? [] : rows`), +-- so a cancelled query is indistinguishable from no data and the whole page -- +-- four tiles, the chart, every campaign row -- reads 0 at once. Measured +-- definer vs invoker on the same input: 11,818 buffers / 208ms against +-- 401,791 / 932ms, a 34x reduction in pages touched, byte-identical output. +-- +-- Safe because each function was already doing its own authorisation and never +-- relied on RLS for it: every base-table read is gated by +-- `_id in (select id from owned)`, and `owned` is `owner_id = auth.uid()`. +-- Note it is `in` and not `not in`, so an anon caller (auth.uid() null) gets an +-- empty `owned` and therefore zero rows rather than everything. Verified with a +-- stranger's JWT: 0 rows from all five. search_path is pinned on all five, as +-- definer requires. +-- +-- Bodies are otherwise untouched -- this changes only the security mode. +-- +-- Apply via psql over the pooler / MCP (prod history diverged), not `db push`. + +create or replace function public.ad_account_series( + p_since timestamptz default null, + p_bucket_seconds integer default 86400 +) +returns table ( + bucket timestamptz, + impressions bigint, + free_impressions bigint, + clicks bigint, + free_clicks bigint, + spent_cents bigint +) +language sql +stable +security definer +set search_path to 'public' +as $function$ + with b as ( + select make_interval(secs => greatest(coalesce(p_bucket_seconds, 86400), 60)) as step + ), + owned as ( + select id from public.ad_campaigns where owner_id = auth.uid() + ), + ev as ( + select date_bin((select step from b), i.ts, timestamptz 'epoch') as bucket, + case when i.tier = 'free' then 0 else 1 end as imp, + case when i.tier = 'free' then 1 else 0 end as free_imp, + 0 as clk, + 0 as free_clk, + 0 as spent + from public.ad_impressions i + where i.campaign_id in (select id from owned) + and not i.duplicate + and (p_since is null or i.ts >= p_since) + union all + select date_bin((select step from b), cl.ts, timestamptz 'epoch'), + 0, + 0, + case when cl.valid then 1 else 0 end, + case when not cl.valid and cl.tier = 'free' then 1 else 0 end, + case when cl.valid then cl.charged_cents else 0 end + from public.ad_clicks cl + where cl.campaign_id in (select id from owned) + and (p_since is null or cl.ts >= p_since) + ) + select bucket, + sum(imp)::bigint, + sum(free_imp)::bigint, + sum(clk)::bigint, + sum(free_clk)::bigint, + sum(spent)::bigint + from ev + group by bucket + order by bucket; +$function$; + +create or replace function public.ad_campaign_totals( + p_since timestamptz default null +) +returns table ( + campaign_id uuid, + impressions bigint, + free_impressions bigint, + clicks bigint, + free_clicks bigint, + spent_cents bigint +) +language sql +stable +security definer +set search_path to 'public' +as $function$ + with owned as ( + select id from public.ad_campaigns where owner_id = auth.uid() + ), + ev as ( + select i.campaign_id, + case when i.tier = 'free' then 0 else 1 end as imp, + case when i.tier = 'free' then 1 else 0 end as free_imp, + 0 as clk, + 0 as free_clk, + 0 as spent + from public.ad_impressions i + where i.campaign_id in (select id from owned) + and not i.duplicate + and (p_since is null or i.ts >= p_since) + union all + select cl.campaign_id, + 0, + 0, + case when cl.valid then 1 else 0 end, + case when not cl.valid and cl.tier = 'free' then 1 else 0 end, + case when cl.valid then cl.charged_cents else 0 end + from public.ad_clicks cl + where cl.campaign_id in (select id from owned) + and (p_since is null or cl.ts >= p_since) + ) + select campaign_id, + sum(imp)::bigint, + sum(free_imp)::bigint, + sum(clk)::bigint, + sum(free_clk)::bigint, + sum(spent)::bigint + from ev + group by campaign_id; +$function$; + +create or replace function public.ad_slot_totals( + p_since timestamptz default null +) +returns table ( + slot_id uuid, + impressions bigint, + free_impressions bigint, + clicks bigint, + free_clicks bigint, + invalid_clicks bigint, + earned_cents bigint +) +language sql +stable +security definer +set search_path to 'public' +as $function$ + with owned as ( + select id from public.ad_slots where owner_id = auth.uid() + ), + ev as ( + select i.slot_id, + case when i.tier = 'free' then 0 else 1 end as imp, + case when i.tier = 'free' then 1 else 0 end as free_imp, + 0 as clk, + 0 as free_clk, + 0 as invalid_clk, + 0 as earned + from public.ad_impressions i + where i.slot_id in (select id from owned) + and not i.duplicate + and (p_since is null or i.ts >= p_since) + union all + select cl.slot_id, + 0, + 0, + case when cl.valid then 1 else 0 end, + case when not cl.valid and cl.tier = 'free' then 1 else 0 end, + case when not cl.valid and cl.tier <> 'free' then 1 else 0 end, + case when cl.valid then coalesce(cl.publisher_earn_cents, 0) else 0 end + from public.ad_clicks cl + where cl.slot_id in (select id from owned) + and (p_since is null or cl.ts >= p_since) + ) + select slot_id, + sum(imp)::bigint, + sum(free_imp)::bigint, + sum(clk)::bigint, + sum(free_clk)::bigint, + sum(invalid_clk)::bigint, + sum(earned)::bigint + from ev + group by slot_id; +$function$; + +create or replace function public.ad_campaign_daily_series( + days integer default 30 +) +returns table ( + campaign_id uuid, + day date, + impressions bigint, + clicks bigint, + spent_cents bigint +) +language sql +stable +security definer +set search_path to 'public' +as $function$ + with bounds as ( + select greatest(coalesce(days, 30), 1) as n + ), + since as ( + select ((now() at time zone 'UTC')::date - (n - 1))::timestamptz as from_ts + from bounds + ), + owned as ( + select id from public.ad_campaigns where owner_id = auth.uid() + ), + imps as ( + select i.campaign_id, + (i.ts at time zone 'UTC')::date as day, + count(*)::bigint as impressions + from public.ad_impressions i + where i.campaign_id in (select id from owned) + and not i.duplicate + and i.ts >= (select from_ts from since) + group by 1, 2 + ), + clk as ( + select cl.campaign_id, + (cl.ts at time zone 'UTC')::date as day, + count(*)::bigint as clicks, + coalesce(sum(cl.charged_cents), 0)::bigint as spent_cents + from public.ad_clicks cl + where cl.valid + and cl.campaign_id in (select id from owned) + and cl.ts >= (select from_ts from since) + group by 1, 2 + ) + select coalesce(i.campaign_id, c.campaign_id) as campaign_id, + coalesce(i.day, c.day) as day, + coalesce(i.impressions, 0) as impressions, + coalesce(c.clicks, 0) as clicks, + coalesce(c.spent_cents, 0) as spent_cents + from imps i + full outer join clk c + on c.campaign_id = i.campaign_id and c.day = i.day; +$function$; + +create or replace function public.ad_slot_daily_series( + days integer default 30 +) +returns table ( + slot_id uuid, + day date, + clicks bigint, + earned_cents bigint +) +language sql +stable +security definer +set search_path to 'public' +as $function$ + with bounds as ( + select greatest(coalesce(days, 30), 1) as n + ), + since as ( + select ((now() at time zone 'UTC')::date - (n - 1))::timestamptz as from_ts + from bounds + ), + owned as ( + select id from public.ad_slots where owner_id = auth.uid() + ) + select cl.slot_id, + (cl.ts at time zone 'UTC')::date as day, + count(*)::bigint as clicks, + coalesce(sum(cl.publisher_earn_cents), 0)::bigint as earned_cents + from public.ad_clicks cl + where cl.valid + and cl.slot_id in (select id from owned) + and cl.ts >= (select from_ts from since) + group by 1, 2; +$function$; diff --git a/tests/ads-earnings-free-tier.test.ts b/tests/ads-earnings-free-tier.test.ts index 7f2d8285..5a653e3f 100644 --- a/tests/ads-earnings-free-tier.test.ts +++ b/tests/ads-earnings-free-tier.test.ts @@ -29,7 +29,7 @@ const failingClient = (): any => ({ describe("getSlotTotalsSince", () => { it("keeps free-tier delivery the paid column alone would hide", async () => { - const totals = await getSlotTotalsSince( + const { data: totals } = await getSlotTotalsSince( clientReturning([ { slot_id: "slot-rss", @@ -53,7 +53,7 @@ describe("getSlotTotalsSince", () => { it("reports invalid clicks separately instead of as delivery", async () => { // Folding bot clicks into the free bucket to make them visible would put a // 16% CTR on the page. They are counted, and counted apart. - const totals = await getSlotTotalsSince( + const { data: totals } = await getSlotTotalsSince( clientReturning([ { slot_id: "s1", @@ -74,7 +74,7 @@ describe("getSlotTotalsSince", () => { }); it("coerces the bigint-as-string counts PostgREST returns", async () => { - const totals = await getSlotTotalsSince( + const { data: totals } = await getSlotTotalsSince( clientReturning([ { slot_id: "s1", @@ -100,8 +100,11 @@ describe("getSlotTotalsSince", () => { }); it("returns an empty map rather than throwing when the RPC fails", async () => { - const totals = await getSlotTotalsSince(failingClient(), null); + const { data: totals, failed } = await getSlotTotalsSince(failingClient(), null); expect(totals.size).toBe(0); + // ...and says so, so the page can print "couldn't load" over the zero row + // instead of asserting the site earned nothing. + expect(failed).toBe(true); // Callers fall back to the zero row, so a failed RPC renders a quiet page // rather than a 500. expect(totals.get("missing") ?? EMPTY_SLOT_TOTALS).toEqual(EMPTY_SLOT_TOTALS); @@ -110,7 +113,7 @@ describe("getSlotTotalsSince", () => { describe("getCampaignTotalsSince", () => { it("counts both tiers, so an all-free campaign is not a dead row", async () => { - const totals = await getCampaignTotalsSince( + const { data: totals } = await getCampaignTotalsSince( clientReturning([ { campaign_id: "c1", diff --git a/tests/ads-stats-box.test.ts b/tests/ads-stats-box.test.ts index b658b6c5..6c5af8c0 100644 --- a/tests/ads-stats-box.test.ts +++ b/tests/ads-stats-box.test.ts @@ -84,7 +84,7 @@ describe("getAccountSeries", () => { const axis = bucketAxis(range, NOW); const rows = [row(new Date(axis.at(-1)!).toISOString(), { free_impressions: 240, free_clicks: 3 })]; - const points = await getAccountSeries(clientReturning(rows), range, NOW); + const { data: points } = await getAccountSeries(clientReturning(rows), range, NOW); const totals = sumSeries(points); expect(totals.impressions).toBe(0); // nothing was billable... @@ -102,7 +102,7 @@ describe("getAccountSeries", () => { const since = rangeSince(range, NOW)!; const rows = [row(new Date(bucketOf(since, range)).toISOString(), { impressions: 7 })]; - const points = await getAccountSeries(clientReturning(rows), range, NOW); + const { data: points } = await getAccountSeries(clientReturning(rows), range, NOW); expect(sumSeries(points).impressions, `${range.id} dropped its first bucket`).toBe(7); } }); @@ -122,13 +122,28 @@ describe("getAccountSeries", () => { it("zero-fills the whole axis so a quiet range still draws a line", async () => { const range = byId("1d"); - const points = await getAccountSeries(clientReturning([]), range, NOW); + const { data: points, failed } = await getAccountSeries(clientReturning([]), range, NOW); expect(points).toHaveLength(bucketAxis(range, NOW).length); expect(sumSeries(points)).toEqual(EMPTY_TOTALS); + // A genuinely quiet range is not a failure, and must not raise the banner. + expect(failed).toBe(false); }); it("renders an empty range rather than throwing when the RPC fails", async () => { - const points = await getAccountSeries(failingClient(), byId("1h"), NOW); + const { data: points } = await getAccountSeries(failingClient(), byId("1h"), NOW); expect(sumSeries(points)).toEqual(EMPTY_TOTALS); }); + + it("says it failed, so the zeros are not reported as real delivery", async () => { + // The whole point: a cancelled query and a quiet range both sum to zero. + // Without this flag the dashboard presented the first as the second, which + // is how a network delivering 176k impressions showed four zeros and read + // as a dead pipeline. + const failing = await getAccountSeries(failingClient(), byId("1h"), NOW); + const quiet = await getAccountSeries(clientReturning([]), byId("1h"), NOW); + + expect(sumSeries(failing.data)).toEqual(sumSeries(quiet.data)); + expect(failing.failed).toBe(true); + expect(quiet.failed).toBe(false); + }); });