|
1 | | -// /api/ads/v1/earnings — the account's ad money and delivery, for a token caller. |
2 | | -// |
3 | | -// GET ?days=7|30|90|365 |
4 | | -// |
5 | | -// The same model /dashboard/ads/earnings renders, for something holding an API |
6 | | -// token. It exists because the alternative for a client that wants fleet totals |
7 | | -// is one /campaigns/[id] request per campaign, and the account is past 170 of |
8 | | -// them — see `crawlproof dashboard`, which polls this on a timer. |
9 | | -// |
10 | | -// Same auth as the rest of /api/ads/v1/*: `Authorization: Bearer crp_…`. |
11 | | - |
12 | 1 | import { NextResponse, type NextRequest } from "next/server"; |
13 | | - |
14 | 2 | import { serviceClient } from "@/lib/supabase/service"; |
15 | 3 | import { authenticateBearer } from "@/lib/sp/apiAuth"; |
16 | 4 | import { loadEarnings } from "@/lib/ads/earnings-data"; |
| 5 | +import { loadTokenDelivery } from "@/lib/ads/token-earnings"; |
17 | 6 |
|
18 | 7 | export const runtime = "nodejs"; |
19 | 8 | export const dynamic = "force-dynamic"; |
20 | 9 |
|
21 | | -const ALLOWED_DAYS = [7, 30, 90, 365]; |
22 | | - |
23 | | -/** An unknown window falls back to 30 rather than reaching the query planner. */ |
24 | 10 | export function parseDays(raw: string | null): number { |
25 | 11 | const n = Number(raw); |
26 | | - return ALLOWED_DAYS.includes(n) ? n : 30; |
| 12 | + return [7, 30, 90, 365].includes(n) ? n : 30; |
27 | 13 | } |
28 | 14 |
|
29 | 15 | export async function GET(req: NextRequest) { |
30 | 16 | const auth = await authenticateBearer(req); |
31 | 17 | if (!auth.ok) return NextResponse.json({ error: auth.error }, { status: auth.status }); |
32 | | - |
33 | 18 | const days = parseDays(req.nextUrl.searchParams.get("days")); |
34 | | - const sb = serviceClient(); |
35 | | - // The service client has no RLS. loadEarnings filters every table by |
36 | | - // owner_id itself, which is what makes passing it here safe. |
37 | | - const model = await loadEarnings(sb, auth.userId, days); |
38 | | - |
39 | | - // The windowed delivery figures come from RPCs that are `security definer` |
40 | | - // and filter on `auth.uid()`. A service client has no auth.uid(), so they |
41 | | - // return nothing and every impression count arrives as a confident zero. |
42 | | - // The stats views are `security_invoker` and granted to service_role, so |
43 | | - // they can be read directly. They are lifetime rather than windowed, which |
44 | | - // is why this only replaces figures that came back empty, and why the answer |
45 | | - // says which it gave you. |
46 | | - const delivery = await lifetimeDelivery(sb, model); |
47 | | - return NextResponse.json({ ...model, ...delivery }); |
48 | | -} |
49 | | - |
50 | | -type StatsRow = { |
51 | | - impressions: number | null; |
52 | | - free_impressions: number | null; |
53 | | - clicks: number | null; |
54 | | - free_clicks: number | null; |
55 | | -}; |
56 | | - |
57 | | -/** |
58 | | - * What the stats views actually mean, which is not what the column names |
59 | | - * suggest and is worth writing down once: |
60 | | - * |
61 | | - * impressions paid tier, non-duplicate |
62 | | - * free_impressions free tier, non-duplicate -> delivery is the SUM of both |
63 | | - * clicks **valid** clicks, either tier -> delivery is this alone |
64 | | - * free_clicks free tier and **not valid** -> fraud/duplicate, NOT delivery |
65 | | - * |
66 | | - * So impressions add up and clicks do not. Adding `free_clicks` into clicks |
67 | | - * would fold invalid clicks into the CTR, which is the one number a click |
68 | | - * fraud problem would show up in. |
69 | | - */ |
70 | | -const sum = (rows: StatsRow[], key: keyof StatsRow) => |
71 | | - rows.reduce((total, row) => total + (Number(row[key]) || 0), 0); |
72 | | - |
73 | | -/** |
74 | | - * Read a stats view for a list of ids, in chunks. |
75 | | - * |
76 | | - * PostgREST puts `in.(…)` in the query string, and this account is past 180 |
77 | | - * campaigns, so one call is a 7KB URL that comes back empty rather than |
78 | | - * erroring. That empty answer is exactly what a network with no delivery looks |
79 | | - * like, which is how it went unnoticed. |
80 | | - */ |
81 | | -async function readStats( |
82 | | - sb: ReturnType<typeof serviceClient>, |
83 | | - view: "ad_campaign_stats" | "ad_slot_stats", |
84 | | - key: "campaign_id" | "slot_id", |
85 | | - ids: string[], |
86 | | -): Promise<{ rows: StatsRow[]; failed: boolean }> { |
87 | | - const rows: StatsRow[] = []; |
88 | | - let failed = false; |
89 | | - const CHUNK = 50; |
90 | | - for (let i = 0; i < ids.length; i += CHUNK) { |
91 | | - const { data, error } = await sb |
92 | | - .from(view) |
93 | | - .select("impressions, free_impressions, clicks, free_clicks") |
94 | | - .in(key, ids.slice(i, i + CHUNK)); |
95 | | - if (error) failed = true; |
96 | | - else rows.push(...((data ?? []) as StatsRow[])); |
| 19 | + try { |
| 20 | + const sb = serviceClient(); |
| 21 | + const model = await loadEarnings(sb, auth.userId, days, loadTokenDelivery(sb, auth.userId, days)); |
| 22 | + if (model.statsUnavailable) throw new Error("incomplete_reporting"); |
| 23 | + return NextResponse.json({ ...model, deliveryWindow: "range", clickSemantics: "accepted_billed_plus_free" }, { |
| 24 | + headers: { "cache-control": "private, no-store" }, |
| 25 | + }); |
| 26 | + } catch { |
| 27 | + console.error("[ads] Earnings response unavailable"); |
| 28 | + return NextResponse.json({ error: "Ad reporting is temporarily unavailable. Please retry.", statsUnavailable: true }, { |
| 29 | + status: 503, headers: { "retry-after": "5", "cache-control": "no-store" }, |
| 30 | + }); |
97 | 31 | } |
98 | | - return { rows, failed }; |
99 | | -} |
100 | | - |
101 | | -async function lifetimeDelivery( |
102 | | - sb: ReturnType<typeof serviceClient>, |
103 | | - model: Awaited<ReturnType<typeof loadEarnings>>, |
104 | | -) { |
105 | | - const t = model.totals; |
106 | | - const empty = !t.advImpressions && !t.advClicks && !t.pubImpressions && !t.pubClicks; |
107 | | - if (!empty) return { deliveryWindow: "range" as const }; |
108 | | - |
109 | | - const campaignIds = model.campaigns.map((c) => c.id); |
110 | | - const slotIds = model.slots.map((s) => s.id); |
111 | | - if (!campaignIds.length && !slotIds.length) return { deliveryWindow: "range" as const }; |
112 | | - |
113 | | - const [c, s] = await Promise.all([ |
114 | | - readStats(sb, "ad_campaign_stats", "campaign_id", campaignIds), |
115 | | - readStats(sb, "ad_slot_stats", "slot_id", slotIds), |
116 | | - ]); |
117 | | - |
118 | | - return { |
119 | | - deliveryWindow: "lifetime" as const, |
120 | | - statsUnavailable: model.statsUnavailable || c.failed || s.failed, |
121 | | - totals: { |
122 | | - ...t, |
123 | | - advImpressions: sum(c.rows, "impressions") + sum(c.rows, "free_impressions"), |
124 | | - advClicks: sum(c.rows, "clicks"), |
125 | | - advFreeImpressions: sum(c.rows, "free_impressions"), |
126 | | - advPaidImpressions: sum(c.rows, "impressions"), |
127 | | - pubImpressions: sum(s.rows, "impressions") + sum(s.rows, "free_impressions"), |
128 | | - pubClicks: sum(s.rows, "clicks"), |
129 | | - pubFreeImpressions: sum(s.rows, "free_impressions"), |
130 | | - pubPaidImpressions: sum(s.rows, "impressions"), |
131 | | - invalidClicks: sum(c.rows, "free_clicks"), |
132 | | - }, |
133 | | - }; |
134 | 32 | } |
0 commit comments