diff --git a/app/(app)/dashboard/ads/[id]/edit/edit-form.tsx b/app/(app)/dashboard/ads/[id]/edit/edit-form.tsx index bd13d86..da3c66b 100644 --- a/app/(app)/dashboard/ads/[id]/edit/edit-form.tsx +++ b/app/(app)/dashboard/ads/[id]/edit/edit-form.tsx @@ -14,6 +14,8 @@ type Campaign = { destinationUrl: string; dailyBudgetCents: number; bidCredits: number; + /** The controller sets the bid (default). Off: the number below is kept. */ + autobid: boolean; status: string; }; @@ -33,6 +35,7 @@ export function EditCampaignForm({ const [url, setUrl] = useState(campaign.destinationUrl); const [budget, setBudget] = useState(campaign.dailyBudgetCents / 100); const [bid, setBid] = useState((campaign.bidCredits * 5) / 100); // credits → $ + const [autobid, setAutobid] = useState(campaign.autobid); const [creatives, setCreatives] = useState(initial); const [active, setActive] = useState(initial[0]?.format ?? "banner_300x250"); // Which polarity the previews show and the colour pickers edit. Publishers @@ -99,6 +102,8 @@ export function EditCampaignForm({ name, destinationUrl: url, dailyBudgetCents: Math.round(budget * 100), + autobid, + // Only meaningful when autobid is off; the action ignores it otherwise. bidCredits: Math.max(1, Math.round((bid * 100) / 5)), }); if (!s.ok) return setError(s.error); @@ -140,17 +145,39 @@ export function EditCampaignForm({ onChange={(e) => setBudget(Math.max(0, Number(e.target.value)))} /> - +
+ Bid / click + + {!autobid && ( +
+ $ + setBid(Math.max(0.05, Number(e.target.value)))} + /> + /click +
+ )} +
diff --git a/app/(app)/dashboard/ads/[id]/edit/page.tsx b/app/(app)/dashboard/ads/[id]/edit/page.tsx index d5326b9..d65d6d2 100644 --- a/app/(app)/dashboard/ads/[id]/edit/page.tsx +++ b/app/(app)/dashboard/ads/[id]/edit/page.tsx @@ -37,7 +37,7 @@ export default async function EditCampaignPage({ const { data: campaign } = await supabase .from("ad_campaigns") - .select("id, name, destination_url, daily_budget_cents, bid_credits, status, ref_slug") + .select("id, name, destination_url, daily_budget_cents, bid_credits, autobid, status, ref_slug") .eq("id", id) .eq("owner_id", user.id) .maybeSingle(); @@ -84,6 +84,7 @@ export default async function EditCampaignPage({ destinationUrl: campaign.destination_url, dailyBudgetCents: campaign.daily_budget_cents, bidCredits: campaign.bid_credits ?? 4, + autobid: campaign.autobid !== false, status: campaign.status, }} creatives={creatives} diff --git a/app/(app)/dashboard/ads/[id]/page.tsx b/app/(app)/dashboard/ads/[id]/page.tsx index 38a7a5b..e76a7d7 100644 --- a/app/(app)/dashboard/ads/[id]/page.tsx +++ b/app/(app)/dashboard/ads/[id]/page.tsx @@ -5,10 +5,14 @@ import { formatSpec, type AdCreative, type AdFormatId } from "@/lib/ads/formats" import { AdPreview } from "@/components/ads/ad-preview"; import { CampaignActions, RegenerateButton } from "@/components/ads/campaign-actions"; import { CampaignTrend } from "@/components/ads/campaign-trend"; +import { BidHistory } from "@/components/ads/bid-history"; import { getCampaignDailySeries } from "@/lib/ads/series"; +import { getBidHistory } from "@/lib/ads/bids"; +import { paperSpendTodayCents } from "@/lib/ads/autobid"; +import { serviceClient } from "@/lib/supabase/service"; import { campaignDisplayStatus, spendTodayCents, utcToday } from "@/lib/ads/status"; import { promoStateForCampaign } from "@/lib/ads/promos"; -import { TRENDING_CPC_CENTS } from "@/lib/ads/pricing"; +import { CREDIT_CENTS, DEFAULT_BID_CREDITS, TRENDING_CPC_CENTS } from "@/lib/ads/pricing"; export const metadata = { title: "Campaign" }; @@ -86,6 +90,38 @@ export default async function CampaignDetailPage({ // not been applied here yet). Both read as "no promo". const promo = await promoStateForCampaign(supabase, id); + // The bid, who sets it, and its paper ledger: their own read too, for the + // same reason — the columns ride behind a hand-applied migration and a + // missing one must not 404 the campaign. The history comes through the + // service client because the tracker rollup it joins is not readable by a + // session, and ownership was already settled by the select above. + type BidRow = { + autobid?: boolean | null; + paper_spend_today_cents?: number | null; + paper_spend_date?: string | null; + paper_total_cents?: number | null; + }; + let bidRow: BidRow | null = null; + try { + const { data } = await supabase + .from("ad_campaigns") + .select("autobid, paper_spend_today_cents, paper_spend_date, paper_total_cents") + .eq("id", id) + .maybeSingle(); + bidRow = (data as BidRow | null) ?? null; + } catch { + bidRow = null; + } + const autobid = bidRow?.autobid !== false; + const bidCredits = campaign.bid_credits ?? DEFAULT_BID_CREDITS; + const history = await getBidHistory(serviceClient(), { + campaignId: id, + refSlug: campaign.ref_slug, + ownerId: user.id, + currentBid: bidCredits, + days: 30, + }); + const impressions = (stats?.impressions as number) ?? 0; const clicks = (stats?.clicks as number) ?? 0; const freeImpressions = (stats?.free_impressions as number) ?? 0; @@ -200,10 +236,35 @@ export default async function CampaignDetailPage({ )} + {/* The bid and its paper ledger. Paper is what the free-tier clicks would + have cost at the bid — real numbers, no money moved — so the auction + and the pacing can be read and judged before anybody is billed. */} +
+ + + +
+
+
+ +
+ {creatives.length > 0 && (

Creatives

@@ -223,11 +284,12 @@ export default async function CampaignDetailPage({ ); } -function Stat({ label, value }: { label: string; value: string }) { +function Stat({ label, value, note }: { label: string; value: string; note?: string }) { return (
{label}
{value}
+ {note &&
{note}
}
); } diff --git a/app/(app)/dashboard/ads/new/form.tsx b/app/(app)/dashboard/ads/new/form.tsx index 6670144..128b202 100644 --- a/app/(app)/dashboard/ads/new/form.tsx +++ b/app/(app)/dashboard/ads/new/form.tsx @@ -17,7 +17,8 @@ export function NewAdForm() { const router = useRouter(); const [url, setUrl] = useState(""); const [budget, setBudget] = useState(5); // dollars/day - const [bid, setBid] = useState(0.2); // dollars/click (max bid) + // No bid field: the bid is automatic (see lib/ads/autobid.ts). The budget is + // the one number an advertiser sets; the edit page can switch to a typed bid. const [name, setName] = useState(""); // Trending targeting, and with it the 90 days. Off by default: it changes // where the ads run, and that is the advertiser's decision to make. @@ -110,8 +111,7 @@ export function NewAdForm() { name, url, dailyBudgetCents: Math.round(budget * 100), - // $ per click → credits (5¢/credit). Higher bids win more auctions. - bidCredits: Math.max(1, Math.round((bid * 100) / 5)), + // No bid: autobid (the default) sets it from the budget within the hour. brand, creatives, trendingTopics: trending, @@ -165,21 +165,15 @@ export function NewAdForm() { /day
-
+
-
- $ - setBid(Math.max(0.05, Number(e.target.value)))} - /> - /click +
+ Autobid + + Set for you from the budget, hourly. Change it later on the campaign. +
{!display.serving && (

{display.hint}

diff --git a/app/(marketing)/ads/page.tsx b/app/(marketing)/ads/page.tsx index 9b11705..e71b882 100644 --- a/app/(marketing)/ads/page.tsx +++ b/app/(marketing)/ads/page.tsx @@ -112,8 +112,8 @@ export default async function AdsMarketingPage() {
[]; summary?: Partial | null; @@ -189,6 +192,9 @@ export async function saveCampaign(input: { brand: input.brand ?? {}, }; if (org.id) payload.organization_id = org.id; + // Autobid is the column default; only an explicit "keep my bid" is written, + // and it is dropped with the other optional columns on a schema-lag retry. + if (input.autobid === false) payload.autobid = false; // Trending targeting, and the subjects it targets on. The subjects come from // the page's own words rather than anything typed here: a campaign claiming // topics its landing page never mentions is how contextual targeting gets @@ -230,10 +236,11 @@ export async function saveCampaign(input: { // of the schema. Retry without them rather than refusing to create the // campaign: prose is an enhancement, a campaign that cannot be saved is the // whole product failing. Same trade the impression short_code makes. - if (campaign.error && /summary_|trending_topics|topics|schema cache|column/i.test(campaign.error.message ?? "")) { + if (campaign.error && /summary_|trending_topics|topics|autobid|schema cache|column/i.test(campaign.error.message ?? "")) { for (const key of Object.keys(summaryFields)) delete payload[key]; delete payload.trending_topics; delete payload.topics; + delete payload.autobid; campaign = await supabase .from("ad_campaigns") .insert(payload) @@ -285,6 +292,8 @@ export async function updateCampaign(input: { name?: string; dailyBudgetCents?: number; bidCredits?: number; + /** Hand the bid back to the controller (true) or keep the typed one (false). */ + autobid?: boolean; destinationUrl?: string; }): Promise<{ ok: true } | { ok: false; error: string }> { const supabase = await createClient(); @@ -300,9 +309,13 @@ export async function updateCampaign(input: { if (Number.isFinite(input.dailyBudgetCents)) { patch.daily_budget_cents = Math.max(0, Math.round(input.dailyBudgetCents!)); } - if (Number.isFinite(input.bidCredits)) { + // A bid typed while autobid is on would be overwritten within the hour, so + // it is only taken when the form is handing the bid to the person. + const manualBid = input.autobid !== true && Number.isFinite(input.bidCredits); + if (manualBid) { patch.bid_credits = Math.min(200, Math.max(1, Math.round(input.bidCredits!))); } + if (typeof input.autobid === "boolean") patch.autobid = input.autobid; if (typeof input.destinationUrl === "string" && input.destinationUrl.trim()) { const check = isAllowedTargetUrl(input.destinationUrl); if (!check.ok) return { ok: false, error: check.reason }; @@ -311,12 +324,40 @@ export async function updateCampaign(input: { } if (Object.keys(patch).length === 0) return { ok: true }; - const { error } = await supabase + // The bid before the edit, so the history can say what it moved from. + let prevBid: number | null = null; + if (manualBid) { + const { data: before } = await supabase + .from("ad_campaigns") + .select("bid_credits") + .eq("id", input.id) + .eq("owner_id", user.id) + .maybeSingle(); + prevBid = (before?.bid_credits as number | null | undefined) ?? null; + } + + let { error } = await supabase .from("ad_campaigns") .update(patch) .eq("id", input.id) .eq("owner_id", user.id); + // `autobid` rides behind a hand-applied migration; an edit to the name or + // the budget must still land if this deploy is ahead of the schema. + if (error && patch.autobid !== undefined && /autobid|schema cache|column/i.test(error.message ?? "")) { + delete patch.autobid; + if (Object.keys(patch).length === 0) return { ok: false, error: "Autobid is not available on this deployment yet." }; + ({ error } = await supabase.from("ad_campaigns").update(patch).eq("id", input.id).eq("owner_id", user.id)); + } if (error) return { ok: false, error: error.message }; + if (manualBid && prevBid !== patch.bid_credits) { + await recordManualBid(serviceClient(), { + campaignId: input.id, + ownerId: user.id, + prevBidCredits: prevBid, + bidCredits: patch.bid_credits as number, + reason: "set in the dashboard", + }); + } revalidatePath("/dashboard/ads"); revalidatePath(`/dashboard/ads/${input.id}`); return { ok: true }; diff --git a/cli/index.ts b/cli/index.ts index 9ea518c..c147313 100644 --- a/cli/index.ts +++ b/cli/index.ts @@ -358,10 +358,10 @@ async function cmdAds(args: Args): Promise { process.stdout.write(promoLine(json.promo)); return 0; } - if (sub === "show" || sub === "pause" || sub === "resume" || sub === "budget" || sub === "delete") { + if (sub === "show" || sub === "pause" || sub === "resume" || sub === "budget" || sub === "bid" || sub === "delete") { const ref = args.positional[1]; if (!ref) { - console.error(`usage: crawlproof ads ${sub} ${sub === "budget" ? " " : ""}`); + console.error(`usage: crawlproof ads ${sub} ${sub === "budget" ? " " : sub === "bid" ? " auto|" : ""}`); return 2; } const path = `/api/ads/v1/campaigns/${encodeURIComponent(ref)}`; @@ -377,6 +377,17 @@ async function cmdAds(args: Args): Promise { } (method = "PATCH"), (body = { daily_budget_cents: cents }); } + if (sub === "bid") { + // `auto` hands the bid back to the controller; a number keeps that bid. + const want = (args.positional[2] ?? "").toLowerCase(); + const credits = Number(want); + if (want === "auto") (method = "PATCH"), (body = { autobid: true }); + else if (Number.isInteger(credits) && credits >= 1) (method = "PATCH"), (body = { bid_credits: credits, autobid: false }); + else { + console.error("usage: crawlproof ads bid auto|"); + return 2; + } + } if (sub === "delete") { if (!args.flags.yes) { console.error("delete removes the campaign and its metering; pass --yes. Pause keeps the history."); @@ -398,7 +409,8 @@ async function cmdAds(args: Args): Promise { return 0; } const stats = json.stats as Record | undefined; - process.stdout.write(`${json.status} ${json.ref_slug} ${json.name}\n ${json.destination_url}\n ${json.daily_budget_cents}¢/day, bid ${json.bid_credits ?? "default"}\n`); + const bidMode = json.autobid === false ? "set by hand" : "autobid"; + process.stdout.write(`${json.status} ${json.ref_slug} ${json.name}\n ${json.destination_url}\n ${json.daily_budget_cents}¢/day, bid ${json.bid_credits ?? "default"} credits (${bidMode})\n`); if (json.trending_topics) { const topics = (json.topics as string[]) ?? []; process.stdout.write(` trending targeting on${topics.length ? ` — ${topics.join(", ")}` : ""}\n`); @@ -409,6 +421,34 @@ async function cmdAds(args: Args): Promise { process.stdout.write( ` impressions ${stats.impressions} (+${stats.free_impressions} free) · clicks ${stats.clicks} (+${stats.free_clicks} free) · spent ${stats.spent_cents}¢ · visits attributed ${visits?.total ?? 0}\n`, ); + // The paper ledger and the bid history: what the free-tier clicks would + // have cost, and the last few bid decisions with why they were made. + const bid = stats.bid as + | { + paper_spend_today_cents?: number; + paper_total_cents?: number; + events?: { ts: string; bidCredits: number; prevBidCredits: number | null; source: string; reason: string }[]; + history?: { date: string; bidCredits: number | null; impressions: number; clicks: number; visits: number }[]; + } + | undefined; + if (bid) { + process.stdout.write(` paper spend today ${bid.paper_spend_today_cents ?? 0}¢ · paper total ${bid.paper_total_cents ?? 0}¢ (no credits moved)\n`); + const events = (bid.events ?? []).slice(0, 5); + if (events.length) { + process.stdout.write(" bid history (newest first):\n"); + for (const e of events) { + const from = e.prevBidCredits == null ? "" : `${e.prevBidCredits} → `; + process.stdout.write(` ${String(e.ts).slice(0, 16).replace("T", " ")} ${from}${e.bidCredits} ${e.source}: ${e.reason}\n`); + } + } + const days = (bid.history ?? []).slice(-7); + if (days.some((d) => d.impressions || d.clicks || d.visits)) { + process.stdout.write(" last 7 days (bid / impressions / clicks / visits):\n"); + for (const d of days) { + process.stdout.write(` ${d.date} ${String(d.bidCredits ?? "-").padStart(3)} ${String(d.impressions).padStart(6)} ${String(d.clicks).padStart(5)} ${String(d.visits).padStart(5)}\n`); + } + } + } } return 0; } @@ -432,7 +472,7 @@ async function cmdAds(args: Args): Promise { } return 0; } - console.error(`unknown: crawlproof ads ${sub} (expected: create | list | show | pause | resume | budget | delete | trending | trends)`); + console.error(`unknown: crawlproof ads ${sub} (expected: create | list | show | pause | resume | budget | bid | delete | trending | trends)`); return 2; } @@ -652,6 +692,12 @@ COMMANDS ads pause | ads resume | ads budget Change a campaign in place. A ref looks like crawlproof-ad-144. + ads bid auto| + Bids are automatic by default: a pacing controller sets each campaign's + bid from its daily budget and its delivery, hourly. "auto" hands a + campaign back to it; a number keeps that bid until you say otherwise. + "ads show" prints the bid, who set it, and its recent history. + ads delete --yes Remove it, metering included. Pause keeps the history. diff --git a/components/ads/bid-history.tsx b/components/ads/bid-history.tsx new file mode 100644 index 0000000..a3e2adb --- /dev/null +++ b/components/ads/bid-history.tsx @@ -0,0 +1,233 @@ +"use client"; + +import { + Bar, + CartesianGrid, + ComposedChart, + Line, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; +import type { BidEvent, BidHistoryDay } from "@/lib/ads/bids"; +import { AUTOBID_REASON_LABEL, type AutobidReason } from "@/lib/ads/autobid"; +import { CREDIT_CENTS } from "@/lib/ads/pricing"; + +// Bid against what it bought. One chart, three kinds of line: +// +// * the bid, in credits, as a step — it only moves when a decision is made; +// * clicks and tracked visits, the outcomes a bid is supposed to buy; +// * impressions as faint bars underneath, because a bid that wins more of +// the lottery shows up there first. +// +// Visits are the visits the tracker attributed to this campaign on the +// owner's own sites (the click URL carries ?ref=, and the tracker buckets it +// as ad:). They exist only where the destination runs the tracker, so a +// flat visits line beside a live clicks line means "not tracked", not "nobody +// stayed". + +const BID_COLOR = "#a78bfa"; + +function dollars(credits: number | null): string { + if (credits == null) return "—"; + return `$${((credits * CREDIT_CENTS) / 100).toFixed(2)}`; +} + +function reasonLabel(reason: string): string { + return AUTOBID_REASON_LABEL[reason as AutobidReason] ?? reason; +} + +export function BidHistory({ + data, + events, + autobid, + failed = false, +}: { + data: BidHistoryDay[]; + events: BidEvent[]; + autobid: boolean; + failed?: boolean; +}) { + const delivery = data.reduce((sum, p) => sum + p.impressions + p.clicks + p.visits, 0); + const tracked = data.some((p) => p.visits > 0); + + if (failed) { + return ( +
+ Couldn't load the bid history. Try again in a moment. +
+ ); + } + if (delivery === 0 && events.length === 0) { + return ( +
+ No bids recorded yet. Autobid sets the first one within the hour. +
+ ); + } + + return ( +
+
+

Bid vs. clicks

+ + Last {data.length} days · {autobid ? "bid set automatically" : "bid set by hand"} + +
+
+ + + + + new Date(v).toLocaleDateString(undefined, { month: "short", day: "numeric" }) + } + /> + + Math.max(4, Math.ceil(max * 1.2))]} + /> + + + typeof v === "string" ? new Date(v).toLocaleDateString() : "" + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + formatter={((v: any, name: any) => + name === "Bid" ? [`${v} credits (${dollars(Number(v))}/click)`, name] : [v, name]) as any} + /> + + + {tracked && ( + + )} + + + +
+
+ + + Bid, credits (right) + + + + Clicks (left) + + {tracked ? ( + + + Visits on your site (left) + + ) : ( + Visits appear here once the destination runs the CrawlProof tracker. + )} + + + Impressions + +
+ + {events.length > 0 && ( +
+ + + + + + + + + + {events.slice(0, 8).map((e) => ( + + + + + + ))} + +
WhenBidWhy
+ {new Date(e.ts).toLocaleString(undefined, { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + })} + + {e.prevBidCredits != null && e.prevBidCredits !== e.bidCredits + ? `${e.prevBidCredits} → ${e.bidCredits}` + : e.bidCredits} + {dollars(e.bidCredits)} + + {e.source === "manual" ? "Set by hand" : e.source === "seed" ? "Starting bid" : reasonLabel(e.reason)} +
+
+ )} +
+ ); +} diff --git a/lib/ads/autobid.ts b/lib/ads/autobid.ts new file mode 100644 index 0000000..6aacb41 --- /dev/null +++ b/lib/ads/autobid.ts @@ -0,0 +1,228 @@ +// Autobid: the bid a campaign should be making right now, from its budget and +// its delivery. Pure — no clock, no database — so every rule here is testable. +// +// The advertiser sets a daily budget. That is all they set. The bid is what +// this controller says it is, recomputed on the worker's clock (lib/ads/bids.ts +// runs it), and shown to them as a number with a reason beside it. +// +// The controller is a pacing loop, the same shape every ad platform's "maximize +// clicks" strategy takes: +// +// * A campaign BEHIND its daily pace — it has spent less of its budget than +// the fraction of the day that has passed — raises its bid, so it wins more +// of the lottery and catches up. +// * A campaign AHEAD of pace lowers its bid, so it does not run dry by lunch. +// * The bid is capped by what the budget could actually cover: a $5/day +// campaign bidding $2 a click would be finished after two clicks and dark +// for the rest of the day. The cap buys at least MIN_CLICKS_PER_DAY clicks. +// * A campaign already bidding well above the market and still behind pace is +// not losing auctions — there is no inventory for it. Raising further would +// spend more per click for the same delivery, so it holds. +// +// Money is paper on this network (see the migration that added `autobid`): the +// bid decides delivery share and what a click WOULD have cost, and nothing is +// ever debited. That changes nothing here — the controller does not know or +// care whether the spend it paces is real, which is the point of running it. + +import { CREDIT_CENTS, DEFAULT_BID_CREDITS } from "./pricing"; + +/** The lowest bid autobid ever sets. */ +export const AUTOBID_MIN_CREDITS = 1; +/** The highest bid autobid ever sets: 40 credits is $2.00 at rack. */ +export const AUTOBID_MAX_CREDITS = 40; +/** The bid cap buys at least this many clicks from a day's budget. */ +export const MIN_CLICKS_PER_DAY = 5; +/** Spent less than this fraction of what the day so far should have cost: raise. */ +export const BEHIND_PACE_RATIO = 0.7; +/** Spent more than this fraction: lower. */ +export const AHEAD_OF_PACE_RATIO = 1.3; +/** Before this much of the UTC day has passed there is too little signal to move. */ +export const EARLY_DAY_FRACTION = 0.08; +/** Bidding this many times the market median and still behind pace means inventory, not bids, is the limit. */ +export const INVENTORY_LIMITED_MULTIPLE = 2; + +export type AutobidReason = + | "seed" + | "no_budget" + | "capped_by_budget" + | "early_day" + | "budget_reached" + | "outbid" + | "inventory_limited" + | "behind_pace" + | "ahead_of_pace" + | "on_pace"; + +export const AUTOBID_REASON_LABEL: Record = { + seed: "Starting bid", + no_budget: "No daily budget", + capped_by_budget: "Capped by daily budget", + early_day: "Too early in the day to move", + budget_reached: "Today's budget reached", + outbid: "Winning nothing at this bid", + inventory_limited: "Above market, no more inventory to win", + behind_pace: "Behind pace, raised", + ahead_of_pace: "Ahead of pace, lowered", + on_pace: "On pace, held", +}; + +export type AutobidInput = { + /** The bid the campaign is making now, in credits. */ + bidCredits: number; + dailyBudgetCents: number; + /** Spend that counts against today's budget: real plus paper. */ + spentTodayCents: number; + /** How much of the UTC day has passed, 0..1. */ + dayFraction: number; + /** This campaign's fills in the last 24h. */ + impressions24h: number; + clicks24h: number; + /** Live campaigns able to fill the same formats, this one included. */ + competitors: number; + /** Median bid among those competitors, in credits. */ + marketBidCredits: number; +}; + +export type AutobidSignals = AutobidInput & { + /** spent / expected-so-far. Above 1 is ahead of pace. */ + paceRatio: number; + maxBidCredits: number; +}; + +export type AutobidDecision = { + bidCredits: number; + reason: AutobidReason; + changed: boolean; + signals: AutobidSignals; +}; + +/** The most a campaign with this budget should bid: enough for MIN_CLICKS_PER_DAY clicks. */ +export function maxAutobidCredits(dailyBudgetCents: number): number { + const budgetCredits = Math.floor(Math.max(0, dailyBudgetCents) / CREDIT_CENTS); + const perClick = Math.floor(budgetCredits / MIN_CLICKS_PER_DAY); + return Math.max(AUTOBID_MIN_CREDITS, Math.min(AUTOBID_MAX_CREDITS, perClick)); +} + +const clamp = (bid: number, max: number) => + Math.max(AUTOBID_MIN_CREDITS, Math.min(max, Math.round(bid))); + +/** One step up: a quarter more, and at least one credit. */ +export function raiseBid(bid: number, max: number): number { + return clamp(Math.max(bid + 1, Math.ceil(bid * 1.25)), max); +} + +/** One step down: a fifth less, and at least one credit. */ +export function lowerBid(bid: number, max: number): number { + return clamp(Math.min(bid - 1, Math.floor(bid * 0.8)), max); +} + +/** The fraction of the UTC day that has passed at `now`. */ +export function utcDayFraction(now: Date = new Date()): number { + const secs = now.getUTCHours() * 3600 + now.getUTCMinutes() * 60 + now.getUTCSeconds(); + return secs / 86400; +} + +export function decideBid(input: AutobidInput): AutobidDecision { + const current = Number.isFinite(input.bidCredits) && input.bidCredits > 0 + ? Math.round(input.bidCredits) + : DEFAULT_BID_CREDITS; + const max = maxAutobidCredits(input.dailyBudgetCents); + const expected = input.dailyBudgetCents * Math.max(0, Math.min(1, input.dayFraction)); + const paceRatio = expected > 0 ? input.spentTodayCents / expected : 0; + const signals: AutobidSignals = { ...input, bidCredits: current, paceRatio, maxBidCredits: max }; + + const decide = (bidCredits: number, reason: AutobidReason): AutobidDecision => ({ + bidCredits, + reason, + changed: bidCredits !== current, + signals, + }); + + // Nothing to spend: bid the floor so the campaign still rotates as backfill. + if (input.dailyBudgetCents <= 0) return decide(AUTOBID_MIN_CREDITS, "no_budget"); + + // A budget that was lowered under the bid pulls the bid down with it, at any + // hour: the cap is a hard rule, not a pacing preference. + if (current > max) return decide(max, "capped_by_budget"); + + // Today's budget is gone: the auction has already demoted this campaign, and + // the bid it comes back with at 00:00 UTC is the one it had. + if (input.spentTodayCents + current * CREDIT_CENTS > input.dailyBudgetCents) { + return decide(current, "budget_reached"); + } + + if (input.dayFraction < EARLY_DAY_FRACTION) return decide(current, "early_day"); + + // Winning nothing while others do, and below the market: outbid, not unwanted. + if ( + input.impressions24h === 0 && + input.competitors > 1 && + current < input.marketBidCredits + ) { + return decide(raiseBid(current, max), "outbid"); + } + + if (paceRatio < BEHIND_PACE_RATIO) { + // Already paying well over the going rate and still behind: the constraint + // is inventory. A higher bid buys the same fills at a higher price. + if ( + input.marketBidCredits > 0 && + current >= input.marketBidCredits * INVENTORY_LIMITED_MULTIPLE && + input.impressions24h > 0 + ) { + return decide(current, "inventory_limited"); + } + return decide(raiseBid(current, max), "behind_pace"); + } + + if (paceRatio > AHEAD_OF_PACE_RATIO) return decide(lowerBid(current, max), "ahead_of_pace"); + + return decide(current, "on_pace"); +} + +/** Median of a list of bids; 0 when empty. */ +export function medianBid(bids: number[]): number { + const sorted = bids.filter((b) => Number.isFinite(b) && b > 0).sort((a, b) => a - b); + if (!sorted.length) return 0; + const mid = Math.floor(sorted.length / 2); + return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2; +} + +// ---------------------------------------------------------------- paper tier + +export type PaperBudgetFields = { + bid_credits?: number | null; + daily_budget_cents: number; + paper_spend_today_cents?: number | null; + paper_spend_date?: string | null; +}; + +/** Paper spend that counts against today's cap, mirroring spendTodayCents. */ +export function paperSpendTodayCents(c: PaperBudgetFields, today: string): number { + return c.paper_spend_date === today ? (c.paper_spend_today_cents ?? 0) : 0; +} + +/** True when one more paper click at this bid would exceed the daily budget. */ +export function isPaperBudgetReached(c: PaperBudgetFields, today: string): boolean { + const bid = c.bid_credits ?? DEFAULT_BID_CREDITS; + return paperSpendTodayCents(c, today) + bid * CREDIT_CENTS > c.daily_budget_cents; +} + +/** + * Weight a campaign carries in the free-tier lottery. + * + * The free tier used to weight everything at 1: nobody was paying, so a bid + * bought nothing. It is a paper auction now — the same bid-weighted lottery as + * the paid tier, so bids decide delivery share and the pacing controller has + * something to pace. A campaign whose paper budget is spent for the day drops + * to a token weight: it still rotates (every live ad rotates, always), but + * behind everything that still has budget, exactly as it would on the paid + * tier. The migration that introduced `autobid` explains the why. + */ +export const PAPER_EXHAUSTED_WEIGHT = 0.5; + +export function paperWeight(c: PaperBudgetFields, today: string): number { + const bid = c.bid_credits ?? DEFAULT_BID_CREDITS; + if (c.daily_budget_cents <= 0) return PAPER_EXHAUSTED_WEIGHT; + return isPaperBudgetReached(c, today) ? PAPER_EXHAUSTED_WEIGHT : Math.max(1, bid); +} diff --git a/lib/ads/bids.ts b/lib/ads/bids.ts new file mode 100644 index 0000000..364741c --- /dev/null +++ b/lib/ads/bids.ts @@ -0,0 +1,448 @@ +// Bids as rows: the autobid sweep, the bid history a campaign page plots, and +// the paper charge a free-tier click records. +// +// The controller itself is lib/ads/autobid.ts and is pure. This module is the +// part that touches the database, and it is written against a SupabaseClient +// so the worker (service role) and the server actions can both call it. + +import type { SupabaseClient } from "@supabase/supabase-js"; +import { + decideBid, + medianBid, + paperSpendTodayCents, + utcDayFraction, + type AutobidDecision, +} from "./autobid"; +import { CREDIT_CENTS, DEFAULT_BID_CREDITS } from "./pricing"; +import { spendTodayCents, utcToday } from "./status"; + +// ------------------------------------------------------------------ sweep + +/** A bid held this long without a decision gets one anyway, so history has a point per day. */ +export const AUTOBID_HEARTBEAT_MS = 24 * 60 * 60 * 1000; +/** Delivery window the controller reads: the last day. */ +export const AUTOBID_ACTIVITY_WINDOW_MS = 24 * 60 * 60 * 1000; + +type SweepCampaign = { + id: string; + owner_id: string; + status: string; + autobid: boolean | null; + bid_credits: number | null; + daily_budget_cents: number; + spend_today_cents: number | null; + spend_date: string | null; + paper_spend_today_cents: number | null; + paper_spend_date: string | null; + bid_updated_at: string | null; +}; + +type BidRow = { + campaign_id: string; + owner_id: string; + ts: string; + bid_credits: number; + prev_bid_credits: number | null; + source: "auto" | "manual" | "seed"; + reason: string; + signals: Record; +}; + +export type AutobidSweepResult = { + considered: number; + changed: number; + seeded: number; + held: number; + /** The read that failed, when one did. Nothing was written. */ + failed?: string; +}; + +const num = (v: unknown, fallback = 0): number => { + const x = Number(v); + return Number.isFinite(x) ? x : fallback; +}; + +/** + * Recompute the bid of every live autobid campaign, once. + * + * Reads the campaigns, the formats each can fill (its ready creatives), and + * a day of delivery per campaign, then runs decideBid over each. A changed bid + * is written back to the campaign and to ad_bids with the signals that moved + * it; an unchanged one is logged only when nothing has been logged for a day, + * so a flat chart still has a point per day and the table stays small. + * + * Competition is per format: a campaign's competitors are every other live + * campaign with a ready creative in any format this one has. The market bid + * is the median across them. On a network where nobody has a third-party + * advertiser this is the whole point — the campaigns bid against each other. + */ +export async function runAutobidSweep( + sb: SupabaseClient, + now: Date = new Date(), +): Promise { + const result: AutobidSweepResult = { considered: 0, changed: 0, seeded: 0, held: 0 }; + + const { data: campaignRows, error: campaignError } = await sb + .from("ad_campaigns") + .select( + "id, owner_id, status, autobid, bid_credits, daily_budget_cents, spend_today_cents, spend_date, paper_spend_today_cents, paper_spend_date, bid_updated_at", + ) + .in("status", ["active", "exhausted"]) + .eq("autobid", true) + .limit(1000); + if (campaignError) return { ...result, failed: `ad_campaigns: ${campaignError.message}` }; + const campaigns = (campaignRows as SweepCampaign[] | null) ?? []; + if (!campaigns.length) return result; + const ids = campaigns.map((c) => c.id); + + const { data: creativeRows, error: creativeError } = await sb + .from("ad_creatives") + .select("campaign_id, format") + .eq("status", "ready") + .in("campaign_id", ids) + .range(0, 4999); + if (creativeError) return { ...result, failed: `ad_creatives: ${creativeError.message}` }; + + const since = new Date(now.getTime() - AUTOBID_ACTIVITY_WINDOW_MS).toISOString(); + const { data: activityRows, error: activityError } = await sb.rpc("ad_autobid_activity", { + p_since: since, + }); + if (activityError) return { ...result, failed: `ad_autobid_activity: ${activityError.message}` }; + + // Who competes with whom: campaigns by format, then the union per campaign. + const formatsByCampaign = new Map>(); + const campaignsByFormat = new Map>(); + for (const row of (creativeRows as { campaign_id: string; format: string }[] | null) ?? []) { + if (!formatsByCampaign.has(row.campaign_id)) formatsByCampaign.set(row.campaign_id, new Set()); + formatsByCampaign.get(row.campaign_id)!.add(row.format); + if (!campaignsByFormat.has(row.format)) campaignsByFormat.set(row.format, new Set()); + campaignsByFormat.get(row.format)!.add(row.campaign_id); + } + const bidOf = new Map(campaigns.map((c) => [c.id, c.bid_credits ?? DEFAULT_BID_CREDITS])); + + const activity = new Map(); + for (const row of (activityRows as { campaign_id: string; impressions: unknown; clicks: unknown }[] | null) ?? []) { + activity.set(row.campaign_id, { impressions: num(row.impressions), clicks: num(row.clicks) }); + } + + const today = utcToday(now); + const dayFraction = utcDayFraction(now); + const nowIso = now.toISOString(); + // A seed row is stamped a second earlier than the decision that follows it, + // so "newest first" reads seed → first decision in the right order. + const seedIso = new Date(now.getTime() - 1000).toISOString(); + const heartbeatBefore = now.getTime() - AUTOBID_HEARTBEAT_MS; + + const inserts: BidRow[] = []; + const updates: { id: string; bid_credits: number }[] = []; + const touches: string[] = []; + + for (const c of campaigns) { + result.considered += 1; + const current = c.bid_credits ?? DEFAULT_BID_CREDITS; + + const rivals = new Set(); + for (const format of formatsByCampaign.get(c.id) ?? []) { + for (const id of campaignsByFormat.get(format) ?? []) rivals.add(id); + } + rivals.add(c.id); + const market = medianBid([...rivals].map((id) => bidOf.get(id) ?? DEFAULT_BID_CREDITS)); + const seen = activity.get(c.id) ?? { impressions: 0, clicks: 0 }; + + const decision: AutobidDecision = decideBid({ + bidCredits: current, + dailyBudgetCents: num(c.daily_budget_cents), + spentTodayCents: spendTodayCents(c, today) + paperSpendTodayCents(c, today), + dayFraction, + impressions24h: seen.impressions, + clicks24h: seen.clicks, + competitors: rivals.size, + marketBidCredits: market, + }); + + const neverLogged = !c.bid_updated_at; + if (neverLogged) { + result.seeded += 1; + inserts.push({ + campaign_id: c.id, + owner_id: c.owner_id, + ts: seedIso, + bid_credits: current, + prev_bid_credits: null, + source: "seed", + reason: "seed", + signals: {}, + }); + } + + const stale = !neverLogged && Date.parse(c.bid_updated_at!) < heartbeatBefore; + if (decision.changed) { + result.changed += 1; + updates.push({ id: c.id, bid_credits: decision.bidCredits }); + } else { + result.held += 1; + if (neverLogged || stale) touches.push(c.id); + } + if (decision.changed || neverLogged || stale) { + inserts.push({ + campaign_id: c.id, + owner_id: c.owner_id, + ts: nowIso, + bid_credits: decision.bidCredits, + prev_bid_credits: current, + source: "auto", + reason: decision.reason, + signals: decision.signals, + }); + } + } + + for (const u of updates) { + const { error } = await sb + .from("ad_campaigns") + .update({ bid_credits: u.bid_credits, bid_updated_at: nowIso }) + .eq("id", u.id); + if (error) return { ...result, failed: `ad_campaigns update: ${error.message}` }; + } + if (touches.length) { + const { error } = await sb + .from("ad_campaigns") + .update({ bid_updated_at: nowIso }) + .in("id", touches); + if (error) return { ...result, failed: `ad_campaigns touch: ${error.message}` }; + } + if (inserts.length) { + const { error } = await sb.from("ad_bids").insert(inserts); + if (error) return { ...result, failed: `ad_bids insert: ${error.message}` }; + } + return result; +} + +// ---------------------------------------------------------- manual bids + +/** + * Log a bid somebody typed. Called after the campaign row is already updated + * under the caller's own ownership check; the service role writes the log. + */ +export async function recordManualBid( + sb: SupabaseClient, + input: { campaignId: string; ownerId: string; prevBidCredits: number | null; bidCredits: number; reason?: string }, +): Promise { + if (input.prevBidCredits === input.bidCredits) return; + try { + await sb.from("ad_bids").insert({ + campaign_id: input.campaignId, + owner_id: input.ownerId, + bid_credits: input.bidCredits, + prev_bid_credits: input.prevBidCredits, + source: "manual", + reason: input.reason ?? "manual", + signals: {}, + }); + await sb.from("ad_campaigns").update({ bid_updated_at: new Date().toISOString() }).eq("id", input.campaignId); + } catch { + // The log rides behind a hand-applied migration; a missing table must not + // fail the edit that was already made. + } +} + +// ---------------------------------------------------------- paper charge + +/** + * Record what a free-tier click would have cost. Never moves money: the SQL + * refuses any click that billed, and a failure here (table not there yet, + * network) costs the paper ledger one row and nobody anything. + */ +export async function paperCharge( + sb: SupabaseClient, + input: { clickId: string | null | undefined; campaignId: string; bidCredits: number | null | undefined }, +): Promise { + if (!input.clickId) return; + const cents = (input.bidCredits ?? DEFAULT_BID_CREDITS) * CREDIT_CENTS; + try { + await sb.rpc("ad_paper_charge", { p_click: input.clickId, p_campaign: input.campaignId, p_cents: cents }); + } catch { + // see above + } +} + +// ----------------------------------------------------------- bid history + +export type BidHistoryDay = { + /** UTC calendar day, YYYY-MM-DD */ + date: string; + impressions: number; + /** Real clicks on both tiers: billed plus free. */ + clicks: number; + /** Visits the tracker attributed to this campaign on the owner's sites. */ + visits: number; + /** What the day's free-tier clicks would have cost. */ + paperCents: number; + /** What the day's billed clicks did cost. */ + spentCents: number; + /** The bid in force at the end of the day, carried across quiet days. */ + bidCredits: number | null; + /** Mean bid on the fills it won that day, when any recorded one. */ + wonBidCredits: number | null; +}; + +export type BidEvent = { + ts: string; + bidCredits: number; + prevBidCredits: number | null; + source: "auto" | "manual" | "seed"; + reason: string; + signals: Record; +}; + +export type BidHistory = { + days: BidHistoryDay[]; + /** Newest first. */ + events: BidEvent[]; + failed: boolean; +}; + +type HistoryDayRow = { + day: string; + impressions: unknown; + won_bid: unknown; + clicks: unknown; + paper_cents: unknown; + spent_cents: unknown; + bid: unknown; +}; + +type HistoryEventRow = { + ts: string; + bid_credits: unknown; + prev_bid_credits: unknown; + source: string; + reason: string; + signals: unknown; +}; + +/** Zero-filled list of the last `days` UTC calendar days, oldest first. */ +function dayAxis(days: number, now: Date): string[] { + const out: string[] = []; + for (let i = days - 1; i >= 0; i--) { + const d = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() - i)); + out.push(d.toISOString().slice(0, 10)); + } + return out; +} + +/** + * Fold the RPC's per-day rows onto a day axis, carrying the bid forward. + * + * A bid is recorded when it changes (and once a day otherwise), so most days + * have no row of their own; the bid in force on such a day is the last one + * recorded before it. Days before the first record take the first record's + * value — the campaign had that bid before anybody wrote it down — and a + * campaign with no record at all takes its current bid throughout. Pure, so + * the carry rules are testable. + */ +export function foldBidHistory(input: { + axis: string[]; + days: HistoryDayRow[]; + visitsByDay: Map; + bidBefore: number | null; + currentBid: number; +}): BidHistoryDay[] { + const byDay = new Map(input.days.map((r) => [String(r.day).slice(0, 10), r])); + let running: number | null = input.bidBefore; + const out: BidHistoryDay[] = []; + for (const date of input.axis) { + const row = byDay.get(date); + const bid = row && row.bid != null ? num(row.bid) : null; + if (bid != null) running = bid; + out.push({ + date, + impressions: num(row?.impressions), + clicks: num(row?.clicks), + visits: input.visitsByDay.get(date) ?? 0, + paperCents: num(row?.paper_cents), + spentCents: num(row?.spent_cents), + bidCredits: running, + wonBidCredits: row && row.won_bid != null ? num(row.won_bid) : null, + }); + } + const first = out.find((d) => d.bidCredits != null)?.bidCredits ?? input.currentBid; + for (const d of out) { + if (d.bidCredits == null) d.bidCredits = first; + else break; + } + return out; +} + +/** + * Bid vs delivery for one campaign over the last `days`, plus the decision log. + * + * The delivery half comes from ad_campaign_bid_history (one jsonb document, + * so the PostgREST row cap cannot truncate it). Visits come from the tracker's + * daily rollup under the `ad:` bucket across the owner's projects — the + * same attribution the API's campaignStats reports — and only exist where the + * destination runs the tracker. A missing RPC reads as failed with a flat, + * zero-filled axis rather than throwing the page. + */ +export async function getBidHistory( + sb: SupabaseClient, + input: { campaignId: string; refSlug: string; ownerId: string; currentBid: number | null; days?: number }, + now: Date = new Date(), +): Promise { + const days = Math.max(1, Math.floor(input.days ?? 30)); + const axis = dayAxis(days, now); + const currentBid = input.currentBid ?? DEFAULT_BID_CREDITS; + + const visitsByDay = new Map(); + try { + const { data: projects } = await sb.from("projects").select("id").eq("owner_id", input.ownerId); + const ids = ((projects as { id: string }[] | null) ?? []).map((p) => p.id); + if (ids.length) { + const { data: rows } = await sb + .from("tracker_daily_stats") + .select("day, count") + .in("project_id", ids) + .eq("bucket", `ad:${input.refSlug}`) + .gte("day", axis[0]) + .limit(1000); + for (const r of (rows as { day: string; count: unknown }[] | null) ?? []) { + const key = String(r.day).slice(0, 10); + visitsByDay.set(key, (visitsByDay.get(key) ?? 0) + num(r.count)); + } + } + } catch { + // No tracker data is an empty series, not a failed page. + } + + type HistoryPayload = { days?: HistoryDayRow[]; bids?: HistoryEventRow[]; bid_before?: unknown }; + let payload: HistoryPayload | null = null; + let failed = false; + try { + const { data, error } = await sb.rpc("ad_campaign_bid_history", { + p_campaign: input.campaignId, + p_days: days, + }); + if (error) failed = true; + else payload = (data as HistoryPayload | null) ?? null; + } catch { + failed = true; + } + + const daysOut = foldBidHistory({ + axis, + days: payload?.days ?? [], + visitsByDay, + bidBefore: payload?.bid_before == null ? null : num(payload.bid_before), + currentBid, + }); + const events: BidEvent[] = (payload?.bids ?? []).map((b) => ({ + ts: b.ts, + bidCredits: num(b.bid_credits), + prevBidCredits: b.prev_bid_credits == null ? null : num(b.prev_bid_credits), + source: (["auto", "manual", "seed"].includes(b.source) ? b.source : "auto") as BidEvent["source"], + reason: String(b.reason ?? ""), + signals: (b.signals && typeof b.signals === "object" ? b.signals : {}) as Record, + })); + + return { days: daysOut, events, failed }; +} diff --git a/lib/ads/campaign-request.ts b/lib/ads/campaign-request.ts index c255589..a436e5f 100644 --- a/lib/ads/campaign-request.ts +++ b/lib/ads/campaign-request.ts @@ -14,6 +14,11 @@ export type CampaignRequest = { name?: string; dailyBudgetCents?: number; bidCredits?: number; + /** + * Let the controller set the bid (the default). Sending a bid without saying + * otherwise is taken as wanting that bid kept, so it turns autobid off. + */ + autobid?: boolean; status?: CampaignStatus; /** Prefer this campaign where its subject is what people are asking about. */ trendingTopics?: boolean; @@ -72,6 +77,9 @@ export function parseCampaignRequest(body: Record): { ok: true; if (!Number.isFinite(n) || n < 1) return { ok: false, error: "bid_credits must be at least 1." }; request.bidCredits = Math.min(200, Math.round(n)); } + const autobid = asBoolean(body.autobid); + if (autobid !== undefined) request.autobid = autobid; + else if (request.bidCredits !== undefined) request.autobid = false; if (statusRaw === "active" || statusRaw === "draft") request.status = statusRaw; const trending = asBoolean(body.trending_topics ?? body.trendingTopics ?? body.trending); @@ -85,6 +93,8 @@ export type CampaignPatch = { name?: string; dailyBudgetCents?: number; bidCredits?: number; + /** See CampaignRequest.autobid: a bid on its own turns this off. */ + autobid?: boolean; status?: "active" | "paused" | "draft"; trendingTopics?: boolean; topics?: string[]; @@ -109,6 +119,9 @@ export function parseCampaignPatch(body: Record): { ok: true; p if (!Number.isFinite(n) || n < 1) return { ok: false, error: "bid_credits must be at least 1." }; patch.bidCredits = Math.min(200, Math.round(n)); } + const autobid = asBoolean(body.autobid); + if (autobid !== undefined) patch.autobid = autobid; + else if (patch.bidCredits !== undefined) patch.autobid = false; if (body.status !== undefined) { if (body.status !== "active" && body.status !== "paused" && body.status !== "draft") { return { ok: false, error: 'status must be "active", "paused" or "draft".' }; @@ -119,7 +132,7 @@ export function parseCampaignPatch(body: Record): { ok: true; p if (trending !== undefined) patch.trendingTopics = trending; if (body.topics !== undefined) patch.topics = cleanTopics(body.topics); if (!Object.keys(patch).length) { - return { ok: false, error: "Nothing to change: send name, daily_budget_cents, bid_credits, status, trending_topics or topics." }; + return { ok: false, error: "Nothing to change: send name, daily_budget_cents, bid_credits, autobid, status, trending_topics or topics." }; } return { ok: true, patch }; } diff --git a/lib/ads/campaigns.ts b/lib/ads/campaigns.ts index ce7ef8d..f5ef0f9 100644 --- a/lib/ads/campaigns.ts +++ b/lib/ads/campaigns.ts @@ -21,6 +21,7 @@ import { extractSiteBrand, type SiteBrand } from "@/lib/ads/brand"; import { DEFAULT_BID_CREDITS } from "@/lib/ads/pricing"; import { cleanTopics, promoState, type PromoState } from "@/lib/ads/trending"; import { grantTrendingPromo, promoForCampaign, promosForCampaigns } from "@/lib/ads/promos"; +import { getBidHistory, recordManualBid, type BidEvent, type BidHistoryDay } from "@/lib/ads/bids"; export type CampaignSummary = { id: string; @@ -30,6 +31,8 @@ export type CampaignSummary = { destination_url: string; daily_budget_cents: number; bid_credits: number | null; + /** The bid is the controller's (default). False means somebody typed it. */ + autobid?: boolean; created_at?: string; creatives?: number; dashboard_url?: string; @@ -84,10 +87,10 @@ function summaryColumns(summary: AdSummary | null | undefined, domain: string): }; } -const schemaLag = (message: string | undefined) => /organization_id|summary_|trending_topics|topics|schema cache|column/i.test(message ?? ""); +const schemaLag = (message: string | undefined) => /organization_id|summary_|trending_topics|topics|autobid|schema cache|column/i.test(message ?? ""); /** Columns a hand-applied migration may not have created yet. Dropped on retry. */ -const OPTIONAL_COLUMNS = ["organization_id", "trending_topics", "topics"]; +const OPTIONAL_COLUMNS = ["organization_id", "trending_topics", "topics", "autobid"]; const isOptionalColumn = (key: string) => OPTIONAL_COLUMNS.includes(key) || key.startsWith("summary_"); /** @@ -102,15 +105,21 @@ const isOptionalColumn = (key: string) => OPTIONAL_COLUMNS.includes(key) || key. async function targetingFor( sb: SupabaseClient, campaignIds: string[], -): Promise> { - const out = new Map(); +): Promise> { + const out = new Map(); const ids = [...new Set(campaignIds.filter(Boolean))]; if (!ids.length) return out; try { - const { data, error } = await sb.from("ad_campaigns").select("id, trending_topics, topics").in("id", ids); - if (error || !data) return out; - for (const row of data as { id: string; trending_topics: boolean | null; topics: string[] | null }[]) { - out.set(row.id, { trending: !!row.trending_topics, topics: cleanTopics(row.topics ?? []) }); + // `autobid` rides along for the same reason: it is behind its own + // hand-applied migration. Absent, the answer is the column's default. + type Res = { data: unknown[] | null; error: { message?: string } | null }; + let res: Res = await sb.from("ad_campaigns").select("id, trending_topics, topics, autobid").in("id", ids); + if (res.error && schemaLag(res.error.message)) { + res = await sb.from("ad_campaigns").select("id, trending_topics, topics").in("id", ids); + } + if (res.error || !res.data) return out; + for (const row of res.data as { id: string; trending_topics: boolean | null; topics: string[] | null; autobid?: boolean | null }[]) { + out.set(row.id, { trending: !!row.trending_topics, topics: cleanTopics(row.topics ?? []), autobid: row.autobid !== false }); } } catch { return out; @@ -128,6 +137,7 @@ export async function withTargeting( const promo = targeting?.trending ? promoState(await promoForCampaign(sb, campaign.id), now) : null; return { ...campaign, + autobid: targeting?.autobid ?? true, trending_topics: targeting?.trending ?? false, topics: targeting?.topics ?? [], promo: promo && promo.startsAt ? { ...promo, kind: "trending_premium_90" } : null, @@ -220,6 +230,8 @@ export async function createCampaignForUrl(input: { }; if (org.id) payload.organization_id = org.id; if (request.trendingTopics) payload.trending_topics = true; + // Autobid is the column default; only an explicit "keep my bid" is written. + if (request.autobid === false) payload.autobid = false; // The subjects: what the caller said, else what the page is about. The // brand's own words are the honest source — a campaign whose claimed topics // have nothing to do with its landing page is how targeting gets gamed. @@ -288,6 +300,7 @@ export async function listCampaigns(input: { sb: SupabaseClient; userId: string; const state = own?.trending ? promoState(promos.get(campaign.id) ?? null, now) : null; return { ...campaign, + autobid: own?.autobid ?? true, trending_topics: own?.trending ?? false, topics: own?.topics ?? [], promo: state && state.startsAt ? { ...state, kind: "trending_premium_90" } : null, @@ -317,6 +330,20 @@ export type CampaignStats = { free_clicks: number; /** Visits the tracker attributed to this campaign on the caller's own sites, by day. */ visits: { total: number; days: { day: string; visits: number }[] }; + /** + * The bid, who sets it, and what it has been. Paper figures are what the + * free-tier clicks would have cost at the bid; no credit was moved for them. + */ + bid: { + credits: number; + autobid: boolean; + paper_spend_today_cents: number; + paper_total_cents: number; + /** Per UTC day, oldest first: bid in force vs impressions, clicks, visits. */ + history: BidHistoryDay[]; + /** Bid decisions, newest first. */ + events: BidEvent[]; + }; }; const n = (v: unknown): number => { @@ -344,6 +371,33 @@ export async function campaignStats(sb: SupabaseClient, userId: string, campaign for (const item of (rows as { day: string; count: number }[]) ?? []) byDay.set(item.day, (byDay.get(item.day) ?? 0) + n(item.count)); for (const [day, visits] of byDay) days.push({ day, visits }); } + + // The bid and its paper ledger. Their own read: the columns are behind a + // hand-applied migration and a missing one must not empty the stats. + let autobid = true; + let paperToday = 0; + let paperTotal = 0; + try { + const { data: bidRow } = await sb + .from("ad_campaigns") + .select("autobid, paper_spend_today_cents, paper_spend_date, paper_total_cents") + .eq("id", campaign.id) + .maybeSingle(); + const b = (bidRow as Record | null) ?? {}; + autobid = b.autobid !== false; + paperToday = String(b.paper_spend_date ?? "").slice(0, 10) === new Date().toISOString().slice(0, 10) ? n(b.paper_spend_today_cents) : 0; + paperTotal = n(b.paper_total_cents); + } catch { + // defaults above + } + const history = await getBidHistory(sb, { + campaignId: campaign.id, + refSlug: campaign.ref_slug, + ownerId: userId, + currentBid: campaign.bid_credits, + days: 30, + }); + return { impressions: n(r.impressions), clicks: n(r.clicks), @@ -352,6 +406,14 @@ export async function campaignStats(sb: SupabaseClient, userId: string, campaign free_impressions: n(r.free_impressions), free_clicks: n(r.free_clicks), visits: { total: days.reduce((sum, d) => sum + d.visits, 0), days }, + bid: { + credits: campaign.bid_credits ?? DEFAULT_BID_CREDITS, + autobid, + paper_spend_today_cents: paperToday, + paper_total_cents: paperTotal, + history: history.days, + events: history.events, + }, }; } @@ -375,17 +437,29 @@ export async function patchCampaign( } if (patch.trendingTopics !== undefined) update.trending_topics = patch.trendingTopics; if (patch.topics !== undefined) update.topics = patch.topics; + if (patch.autobid !== undefined) update.autobid = patch.autobid; let { data, error } = await sb.from("ad_campaigns").update(update).eq("id", campaign.id).eq("owner_id", userId).select(CAMPAIGN_COLUMNS).single(); - // The targeting columns ride behind a hand-applied migration; a deploy that - // lands first must not make every ordinary edit fail. - if (error && schemaLag(error.message) && (update.trending_topics !== undefined || update.topics !== undefined)) { + // The targeting and autobid columns ride behind a hand-applied migration; a + // deploy that lands first must not make every ordinary edit fail. + if (error && schemaLag(error.message) && (update.trending_topics !== undefined || update.topics !== undefined || update.autobid !== undefined)) { for (const key of Object.keys(update)) if (isOptionalColumn(key)) delete update[key]; - if (!Object.keys(update).length) return { ok: false, status: 503, error: "Trending targeting is not available on this deployment yet." }; + if (!Object.keys(update).length) return { ok: false, status: 503, error: "That setting is not available on this deployment yet." }; ({ data, error } = await sb.from("ad_campaigns").update(update).eq("id", campaign.id).eq("owner_id", userId).select(CAMPAIGN_COLUMNS).single()); } if (error || !data) return { ok: false, status: 500, error: error?.message ?? "Failed to update the campaign." }; + // A typed bid is a bid decision too, and the history should say who made it. + if (patch.bidCredits !== undefined) { + await recordManualBid(sb, { + campaignId: campaign.id, + ownerId: userId, + prevBidCredits: campaign.bid_credits ?? null, + bidCredits: patch.bidCredits, + reason: "set by API", + }); + } + // Turning it on earns the ninety days — once. Turning it off later does not // revoke them: the advertiser was promised a window, not a subscription, and // nothing about a promo whose clicks cost nothing is worth clawing back. diff --git a/lib/ads/serve.ts b/lib/ads/serve.ts index 1eb21ed..06a30a6 100644 --- a/lib/ads/serve.ts +++ b/lib/ads/serve.ts @@ -27,6 +27,8 @@ import { competesForPaid, fillTier, matchTrend, trendWeight, type TrendMatch } f import { promoActiveFor, trendContextFor } from "./trendContext"; import { promoForCampaign } from "./promos"; import { promoState, clickChargeCents } from "./trending"; +import { paperWeight, type PaperBudgetFields } from "./autobid"; +import { paperCharge } from "./bids"; // Server-side ad selection + metering. Runs under the service-role client so // the public serving endpoints can read cross-tenant campaigns/creatives and @@ -278,6 +280,16 @@ export async function serveAd( ]), ); + // Paper spend, for the free-tier auction below. Its own read rather than two + // more columns on the serving join: the columns ride behind a hand-applied + // migration, and a deploy that lands first must not take every unit dark. + // A read that fails means nobody has spent anything today, which is the + // pre-autobid behaviour exactly. + const paperByCampaign = await paperSpendFor( + sb, + candidates.map((row) => oneCampaign(row.ad_campaigns).id), + ); + // Trending targeting, if anybody is using it. Costs one cached query on a // network where nobody is; see lib/ads/trendContext.ts. const trend = await trendContextFor( @@ -354,17 +366,33 @@ export async function serveAd( } // Nothing paid to show: backfill with a real advertiser's ad instead of the - // house ad. Not bid-weighted — nobody is paying, so a high bid buys no - // priority here. A trending match does, because that is about relevance to - // the page rather than about money, and this is the tier every promo - // campaign serves from. + // house ad. This is the PAPER auction: the same bid-weighted lottery as the + // paid tier, weighted by each campaign's bid while its paper daily budget + // lasts and by a token weight once it is spent. Nobody is paying, and no + // credit is ever debited on this tier — but on a network where every fill + // lands here, weighting everything at 1 (as this used to) meant no campaign + // ever bid at all. Now the bids decide delivery share, the clicks record + // what they would have cost, and the pacing controller in lib/ads/autobid.ts + // has something real to pace. A trending match still multiplies the weight, + // because that is about relevance to the page rather than about money, and + // this is the tier every promo campaign serves from. if (!pick && free.length > 0) { tier = "free"; pick = runAuction( - free.map((row) => ({ - bidCredits: trendWeight(1, matchFor(oneCampaign(row.ad_campaigns).id)), - item: row, - })), + free.map((row) => { + const c = oneCampaign(row.ad_campaigns); + const paper = paperByCampaign.get(c.id); + const weight = paperWeight( + { + bid_credits: c.bid_credits ?? DEFAULT_BID_CREDITS, + daily_budget_cents: c.daily_budget_cents, + paper_spend_today_cents: paper?.paper_spend_today_cents ?? 0, + paper_spend_date: paper?.paper_spend_date ?? null, + }, + today, + ); + return { bidCredits: trendWeight(weight, matchFor(c.id)), item: row }; + }), )?.winner; } @@ -404,10 +432,18 @@ export async function serveAd( // fail on the unknown columns and take *all* paid serving down with it. // Retry once without them and fall back to the UUID click URL: a wide URL is // a cosmetic problem, a dropped impression is a lost sale. + // `bid_credits` — what the winner bid on this fill — rides in the same + // optional group: the bid history chart reads it, serving does not need it. const shortCode = generateShortCode(); let { data: imp } = await sb .from("ad_impressions") - .insert({ ...base, short_code: shortCode, src: ctx.src ?? null, duplicate }) + .insert({ + ...base, + short_code: shortCode, + src: ctx.src ?? null, + duplicate, + bid_credits: campaign.bid_credits ?? DEFAULT_BID_CREDITS, + }) .select("id, short_code") .single(); @@ -455,6 +491,37 @@ export async function serveAd( }; } +/** + * Today's paper spend for a set of campaigns, keyed by id. + * + * Tolerant of everything: the columns not existing yet, the client being a + * test double that knows nothing about this table, the network. Any of those + * reads as "nothing spent", which weights the free tier by bid alone — the + * paper auction still runs, only the daily cap goes unenforced until the + * schema catches up. + */ +async function paperSpendFor( + sb: ReturnType, + campaignIds: string[], +): Promise> { + const out = new Map(); + const ids = [...new Set(campaignIds.filter(Boolean))]; + if (!ids.length) return out; + try { + const { data, error } = await sb + .from("ad_campaigns") + .select("id, daily_budget_cents, paper_spend_today_cents, paper_spend_date") + .in("id", ids); + if (error || !Array.isArray(data)) return out; + for (const row of data as (PaperBudgetFields & { id: string })[]) { + if (row && typeof row === "object" && row.id) out.set(row.id, row); + } + } catch { + // see above + } + return out; +} + /** * A campaign's editorial prose, when it still describes where the campaign points. * @@ -588,25 +655,34 @@ export async function resolveClick(input: { const charge = clickChargeCents({ promo, cpcCents: (campaign.bid_credits ?? DEFAULT_BID_CREDITS) * CREDIT_CENTS }); if (validity.valid && promo.active && charge === 0) { - await sb.from("ad_clicks").insert({ - impression_id: input.impressionId ?? null, - slot_id: input.slotId, - campaign_id: campaign.id, - creative_id: input.creativeId ?? null, - visitor_id: visitorId, - ip_hash: ipHash, - geo_country: input.ctx?.country ?? null, - device: input.ctx?.device ?? null, - charged_cents: 0, - publisher_earn_cents: 0, - platform_cut_cents: 0, - valid: false, - tier: "free", + const { data: promoClick } = await sb + .from("ad_clicks") + .insert({ + impression_id: input.impressionId ?? null, + slot_id: input.slotId, + campaign_id: campaign.id, + creative_id: input.creativeId ?? null, + visitor_id: visitorId, + ip_hash: ipHash, + geo_country: input.ctx?.country ?? null, + device: input.ctx?.device ?? null, + charged_cents: 0, + publisher_earn_cents: 0, + platform_cut_cents: 0, + valid: false, + tier: "free", + }) + .select("id") + .maybeSingle(); + await paperCharge(sb, { + clickId: (promoClick as { id?: string } | null)?.id, + campaignId: campaign.id, + bidCredits: campaign.bid_credits, }); } else if (validity.valid) { // Atomic charge: debit advertiser credits, meter the click, accrue the // publisher share + platform fee (unbilled if out of budget/funds). - await sb.rpc("ad_charge_click", { + const { data: charged } = await sb.rpc("ad_charge_click", { p_campaign: campaign.id, p_slot: input.slotId, p_creative: input.creativeId ?? null, @@ -619,6 +695,20 @@ export async function resolveClick(input: { p_cpc_credits: campaign.bid_credits ?? DEFAULT_BID_CREDITS, p_platform_rate: PLATFORM_RATE, }); + // A real click nobody could be billed for — self-deal, out of budget, + // out of credit — is the free tier, and the free tier is a paper + // auction now: record what this click would have cost at the bid. The + // SQL only ever marks a click that is free-tier and unbilled, so a paid + // click can never be relabelled from here. + const row = Array.isArray(charged) ? charged[0] : charged; + const outcome = (row ?? null) as { click_id?: string; charged_cents?: number; valid?: boolean } | null; + if (outcome?.click_id && !outcome.valid && !(outcome.charged_cents ?? 0)) { + await paperCharge(sb, { + clickId: outcome.click_id, + campaignId: campaign.id, + bidCredits: campaign.bid_credits, + }); + } } else { // Invalid (bot / duplicate / forged): record an unbilled click for // analytics, charge nobody. diff --git a/supabase/migrations/20260913120000_ad_autobid.sql b/supabase/migrations/20260913120000_ad_autobid.sql new file mode 100644 index 0000000..0d34813 --- /dev/null +++ b/supabase/migrations/20260913120000_ad_autobid.sql @@ -0,0 +1,270 @@ +-- Autobid, paper money, and a bid history. +-- +-- Every campaign on this network belongs to the account that owns every slot, +-- so serveAd demotes every fill to the free tier and the bid-weighted auction +-- never runs: the free pool weights every campaign at 1. Nobody bids. That +-- means the auction, the pacing and the charge path have never been exercised +-- end to end with real numbers, and there are far more campaigns than there is +-- inventory for them. +-- +-- This turns bidding on for everyone, with PAPER money: +-- +-- * `autobid` (default TRUE, for existing rows too) hands the bid to a pacing +-- controller (lib/ads/autobid.ts) that raises a campaign behind its daily +-- pace and lowers one ahead of it, capped by what the daily budget could +-- cover. The advertiser sets a budget; the bid is computed and shown. +-- * The free tier becomes a paper auction: the same bid-weighted lottery, +-- weighted by the paper bid and gated by a paper daily budget. Each +-- free-tier click records what it WOULD have cost (`paper_cents`) and the +-- campaign accrues `paper_spend_today_cents`. No credit balance is ever +-- debited and nothing is accrued to a publisher. The paid tier — a funded +-- third party — is untouched and still bills through ad_charge_click. +-- * `ad_bids` keeps every bid decision with the signals that drove it, so a +-- campaign page can plot bid against impressions, clicks and the visits +-- the tracker attributes to it. +-- +-- Apply by hand via the Supabase MCP, one file at a time. Every column here is +-- additive with a default, so a deploy that lands either side of it is safe. + +alter table public.ad_campaigns + add column if not exists autobid boolean not null default true, + add column if not exists bid_updated_at timestamptz, + add column if not exists paper_spend_today_cents integer not null default 0, + add column if not exists paper_spend_date date, + add column if not exists paper_total_cents bigint not null default 0; + +-- What the winner bid on this fill. Null on rows written before this landed. +alter table public.ad_impressions + add column if not exists bid_credits integer; + +-- What a free-tier click would have cost at the campaign's bid. Always 0 on a +-- billed click, whose real cost is charged_cents. +alter table public.ad_clicks + add column if not exists paper_cents integer not null default 0; + +create table if not exists public.ad_bids ( + id uuid primary key default gen_random_uuid(), + campaign_id uuid not null references public.ad_campaigns(id) on delete cascade, + owner_id uuid not null references auth.users(id) on delete cascade, + ts timestamptz not null default now(), + bid_credits integer not null check (bid_credits > 0), + prev_bid_credits integer, + -- auto: the controller. manual: somebody typed it. seed: the first row for a + -- campaign, so a chart has a starting point. + source text not null default 'auto' check (source in ('auto', 'manual', 'seed')), + reason text not null default '', + signals jsonb not null default '{}'::jsonb +); +create index if not exists ad_bids_campaign_ts_idx on public.ad_bids (campaign_id, ts desc); + +alter table public.ad_bids enable row level security; +drop policy if exists ad_bids_owner_read on public.ad_bids; +create policy ad_bids_owner_read on public.ad_bids + for select using (auth.uid() = owner_id); +-- Writes come from the worker and the server actions through the service role. +revoke insert, update, delete on public.ad_bids from anon, authenticated; + +-- --------------------------------------------------------------------------- +-- Paper charge: record what a free-tier click would have cost. +-- --------------------------------------------------------------------------- +-- Separate from ad_charge_click on purpose: that function is the hottest SQL +-- in the product and moves real money. This one moves none. It refuses to +-- touch a click that billed (tier 'paid' or charged_cents > 0), so a paper +-- figure can never be written over a real one. +create or replace function public.ad_paper_charge( + p_click uuid, + p_campaign uuid, + p_cents integer +) +returns void +language plpgsql +security definer +set search_path to 'public' +as $$ +declare + v_tier text; + v_spend int; + v_date date; +begin + if p_click is null or p_campaign is null or p_cents is null or p_cents <= 0 then + return; + end if; + + update public.ad_clicks + set paper_cents = p_cents + where id = p_click + and campaign_id = p_campaign + and tier = 'free' + and charged_cents = 0 + returning tier into v_tier; + if v_tier is null then + return; -- not a free-tier click; nothing paper about it + end if; + + select paper_spend_today_cents, paper_spend_date + into v_spend, v_date + from public.ad_campaigns + where id = p_campaign + for update; + if not found then return; end if; + if v_date is distinct from current_date then v_spend := 0; end if; + + update public.ad_campaigns + set paper_spend_today_cents = v_spend + p_cents, + paper_spend_date = current_date, + paper_total_cents = coalesce(paper_total_cents, 0) + p_cents + where id = p_campaign; +end +$$; + +revoke execute on function public.ad_paper_charge(uuid, uuid, integer) from public, anon, authenticated; +grant execute on function public.ad_paper_charge(uuid, uuid, integer) to service_role; + +-- --------------------------------------------------------------------------- +-- Activity since a moment, per campaign, for the autobid sweep. +-- --------------------------------------------------------------------------- +-- One grouped read over the reporting indexes rather than the raw rows through +-- PostgREST, which caps a response at 1000 rows and a day of impressions is +-- ninety thousand. Clicks count real delivery on both tiers: billed, or free +-- and real. Refused clicks (not valid, tier 'paid') are not delivery. +create or replace function public.ad_autobid_activity( + p_since timestamptz +) +returns table(campaign_id uuid, impressions bigint, clicks bigint) +language sql +stable +security definer +set search_path to 'public' +as $$ + with i as ( + select imp.campaign_id, count(*) as n + from public.ad_impressions imp + where imp.ts >= p_since and not imp.duplicate + group by imp.campaign_id + ), + k as ( + select cl.campaign_id, count(*) as n + from public.ad_clicks cl + where cl.ts >= p_since and (cl.valid or cl.tier = 'free') + group by cl.campaign_id + ) + select coalesce(i.campaign_id, k.campaign_id) as campaign_id, + coalesce(i.n, 0)::bigint as impressions, + coalesce(k.n, 0)::bigint as clicks + from i + full outer join k on k.campaign_id = i.campaign_id; +$$; + +revoke execute on function public.ad_autobid_activity(timestamptz) from public, anon, authenticated; +grant execute on function public.ad_autobid_activity(timestamptz) to service_role; + +-- --------------------------------------------------------------------------- +-- Bid history for one campaign: per UTC day, bid vs delivery, plus the log. +-- --------------------------------------------------------------------------- +-- Returns one jsonb document (the 1000-row cap cannot apply): +-- days one entry per day in the window, zero-filled, oldest first: +-- impressions (both tiers), won_bid (mean bid on the fills it +-- won), clicks (billed + free), paper_cents, spent_cents, and the +-- bid recorded that day (last, min, max) or null when none was. +-- bids the most recent 50 bid decisions, newest first. +-- bid_before the last bid recorded before the window, so a caller can carry +-- it forward across days that recorded nothing. +-- +-- A session caller sees only their own campaigns. A null uid is the service +-- role, which has already resolved ownership through the bearer token. +create or replace function public.ad_campaign_bid_history( + p_campaign uuid, + p_days integer default 30 +) +returns jsonb +language plpgsql +stable +security definer +set search_path to 'public' +as $$ +declare + uid uuid := auth.uid(); + v_owner uuid; + n int := greatest(coalesce(p_days, 30), 1); + today date := (now() at time zone 'UTC')::date; + from_day date; + from_ts timestamptz; +begin + select c.owner_id into v_owner from public.ad_campaigns c where c.id = p_campaign; + if not found then return '{}'::jsonb; end if; + if uid is not null and uid <> v_owner then return '{}'::jsonb; end if; + + from_day := today - (n - 1); + from_ts := (from_day::timestamp at time zone 'UTC'); + + return jsonb_build_object( + 'days', coalesce(( + select jsonb_agg(row_to_json(d) order by d.day) + from ( + with axis as ( + select generate_series(from_day, today, interval '1 day')::date as day + ), + imp as ( + select ((i.ts at time zone 'UTC')::date) as day, + count(*) as impressions, + avg(i.bid_credits) filter (where i.bid_credits is not null) as won_bid + from public.ad_impressions i + where i.campaign_id = p_campaign and not i.duplicate and i.ts >= from_ts + group by 1 + ), + clk as ( + select ((k.ts at time zone 'UTC')::date) as day, + count(*) filter (where k.valid or k.tier = 'free') as clicks, + coalesce(sum(k.paper_cents), 0) as paper_cents, + coalesce(sum(k.charged_cents) filter (where k.valid), 0) as spent_cents + from public.ad_clicks k + where k.campaign_id = p_campaign and k.ts >= from_ts + group by 1 + ), + bid as ( + select ((b.ts at time zone 'UTC')::date) as day, + (array_agg(b.bid_credits order by b.ts desc))[1] as bid, + min(b.bid_credits) as min_bid, + max(b.bid_credits) as max_bid + from public.ad_bids b + where b.campaign_id = p_campaign and b.ts >= from_ts + group by 1 + ) + select axis.day, + coalesce(imp.impressions, 0)::bigint as impressions, + round(imp.won_bid::numeric, 2) as won_bid, + coalesce(clk.clicks, 0)::bigint as clicks, + coalesce(clk.paper_cents, 0)::bigint as paper_cents, + coalesce(clk.spent_cents, 0)::bigint as spent_cents, + bid.bid, + bid.min_bid, + bid.max_bid + from axis + left join imp on imp.day = axis.day + left join clk on clk.day = axis.day + left join bid on bid.day = axis.day + ) d + ), '[]'::jsonb), + 'bids', coalesce(( + select jsonb_agg(row_to_json(b) order by b.ts desc) + from ( + select b.ts, b.bid_credits, b.prev_bid_credits, b.source, b.reason, b.signals + from public.ad_bids b + where b.campaign_id = p_campaign + order by b.ts desc + limit 50 + ) b + ), '[]'::jsonb), + 'bid_before', ( + select b.bid_credits + from public.ad_bids b + where b.campaign_id = p_campaign and b.ts < from_ts + order by b.ts desc + limit 1 + ) + ); +end +$$; + +revoke execute on function public.ad_campaign_bid_history(uuid, integer) from public, anon; +grant execute on function public.ad_campaign_bid_history(uuid, integer) to authenticated, service_role; diff --git a/tests/ads-api-requests.test.ts b/tests/ads-api-requests.test.ts index fc7f12c..b4aeb7c 100644 --- a/tests/ads-api-requests.test.ts +++ b/tests/ads-api-requests.test.ts @@ -9,7 +9,8 @@ describe("POST /api/ads/v1/campaigns body", () => { expect(parsed.ok).toBe(true); if (!parsed.ok) return; expect(parsed.url).toBe("https://nichedb.dev/i/17"); - expect(parsed.request).toEqual({ url: "https://nichedb.dev/i/17", name: "NicheDB", dailyBudgetCents: 250, bidCredits: 200 }); + // A bid sent on its own is a bid to keep, so it turns autobid off. + expect(parsed.request).toEqual({ url: "https://nichedb.dev/i/17", name: "NicheDB", dailyBudgetCents: 250, bidCredits: 200, autobid: false }); }); it("refuses what the audit target guard refuses, and a made-up status", () => { @@ -87,7 +88,7 @@ describe("PATCH /api/ads/v1/campaigns/[id] body", () => { expect(parseCampaignPatch({ status: "paused" })).toEqual({ ok: true, patch: { status: "paused" } }); expect(parseCampaignPatch({ daily_budget_cents: 250.6, bid_credits: 900, name: " New " })).toEqual({ ok: true, - patch: { dailyBudgetCents: 251, bidCredits: 200, name: "New" }, + patch: { dailyBudgetCents: 251, bidCredits: 200, name: "New", autobid: false }, }); expect(parseCampaignPatch({})).toMatchObject({ ok: false, error: expect.stringContaining("Nothing to change") }); expect(parseCampaignPatch({ status: "exhausted" })).toMatchObject({ ok: false }); diff --git a/tests/ads-autobid-requests.test.ts b/tests/ads-autobid-requests.test.ts new file mode 100644 index 0000000..7fb1bf0 --- /dev/null +++ b/tests/ads-autobid-requests.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import { parseCampaignPatch, parseCampaignRequest } from "@/lib/ads/campaign-request"; + +// Autobid is the default and the only thing most callers see. A bid sent on +// its own means "keep this bid", so it turns autobid off; saying `autobid` +// explicitly wins either way. + +describe("parseCampaignRequest and autobid", () => { + it("says nothing about autobid when nothing was sent (the column default is on)", () => { + const r = parseCampaignRequest({ url: "https://example.com" }); + expect(r.ok && r.request.autobid).toBeUndefined(); + }); + + it("turns autobid off when a bid is sent without saying otherwise", () => { + const r = parseCampaignRequest({ url: "https://example.com", bid_credits: 6 }); + expect(r.ok && r.request).toMatchObject({ bidCredits: 6, autobid: false }); + }); + + it("keeps autobid on when asked, even beside a bid", () => { + const r = parseCampaignRequest({ url: "https://example.com", bid_credits: 6, autobid: true }); + expect(r.ok && r.request).toMatchObject({ bidCredits: 6, autobid: true }); + }); + + it("reads autobid as a shell would send it", () => { + const r = parseCampaignRequest({ url: "https://example.com", autobid: "false" }); + expect(r.ok && r.request.autobid).toBe(false); + }); +}); + +describe("parseCampaignPatch and autobid", () => { + it("accepts autobid alone as a change", () => { + const r = parseCampaignPatch({ autobid: true }); + expect(r).toEqual({ ok: true, patch: { autobid: true } }); + }); + + it("a typed bid turns autobid off", () => { + const r = parseCampaignPatch({ bid_credits: 9 }); + expect(r).toEqual({ ok: true, patch: { bidCredits: 9, autobid: false } }); + }); + + it("names autobid among the fields when nothing was sent", () => { + const r = parseCampaignPatch({}); + expect(!r.ok && r.error).toMatch(/autobid/); + }); +}); diff --git a/tests/ads-autobid.test.ts b/tests/ads-autobid.test.ts new file mode 100644 index 0000000..72b3b45 --- /dev/null +++ b/tests/ads-autobid.test.ts @@ -0,0 +1,409 @@ +import { describe, expect, it } from "vitest"; +import { + AUTOBID_MAX_CREDITS, + AUTOBID_MIN_CREDITS, + PAPER_EXHAUSTED_WEIGHT, + decideBid, + isPaperBudgetReached, + lowerBid, + maxAutobidCredits, + medianBid, + paperWeight, + raiseBid, + utcDayFraction, + type AutobidInput, +} from "@/lib/ads/autobid"; +import { foldBidHistory, runAutobidSweep } from "@/lib/ads/bids"; +import { CREDIT_CENTS } from "@/lib/ads/pricing"; + +// The bid is automatic: an advertiser sets a daily budget and a pacing +// controller sets the bid from it, hourly. These cases pin the rules that +// decide a bid, the paper tier that gives the free-tier lottery something to +// pace, the carry-forward that turns a sparse decision log into a daily +// series, and the sweep's writes. + +const input = (over: Partial = {}): AutobidInput => ({ + bidCredits: 4, + dailyBudgetCents: 500, // $5 = 100 credits + spentTodayCents: 0, + dayFraction: 0.5, + impressions24h: 100, + clicks24h: 2, + competitors: 10, + marketBidCredits: 4, + ...over, +}); + +describe("maxAutobidCredits", () => { + it("buys at least five clicks from the day's budget", () => { + // 100 credits / 5 clicks = 20 credits a click + expect(maxAutobidCredits(500)).toBe(20); + // $1 = 20 credits → 4 a click + expect(maxAutobidCredits(100)).toBe(4); + }); + it("never goes under the floor or over the ceiling", () => { + expect(maxAutobidCredits(0)).toBe(AUTOBID_MIN_CREDITS); + expect(maxAutobidCredits(10)).toBe(AUTOBID_MIN_CREDITS); + expect(maxAutobidCredits(1_000_000)).toBe(AUTOBID_MAX_CREDITS); + }); +}); + +describe("raiseBid / lowerBid", () => { + it("moves at least one credit each way", () => { + expect(raiseBid(1, 20)).toBe(2); + expect(lowerBid(2, 20)).toBe(1); + }); + it("moves a quarter up and a fifth down on larger bids", () => { + expect(raiseBid(8, 40)).toBe(10); + expect(lowerBid(10, 40)).toBe(8); + }); + it("respects the cap and the floor", () => { + expect(raiseBid(20, 20)).toBe(20); + expect(lowerBid(1, 20)).toBe(1); + }); +}); + +describe("decideBid", () => { + it("raises a campaign behind its daily pace", () => { + // Half the day gone, nothing spent: well under 70% of the expected $2.50. + const d = decideBid(input({ spentTodayCents: 0 })); + expect(d.reason).toBe("behind_pace"); + expect(d.bidCredits).toBe(5); + expect(d.changed).toBe(true); + expect(d.signals.paceRatio).toBe(0); + }); + + it("lowers a campaign ahead of pace", () => { + // Half the day gone, $4 of $5 spent: 160% of expected. + const d = decideBid(input({ bidCredits: 10, spentTodayCents: 400 })); + expect(d.reason).toBe("ahead_of_pace"); + expect(d.bidCredits).toBe(8); + }); + + it("holds a campaign on pace", () => { + const d = decideBid(input({ spentTodayCents: 250 })); + expect(d.reason).toBe("on_pace"); + expect(d.bidCredits).toBe(4); + expect(d.changed).toBe(false); + }); + + it("does not move before the day has said anything", () => { + const d = decideBid(input({ dayFraction: 0.02 })); + expect(d.reason).toBe("early_day"); + expect(d.changed).toBe(false); + }); + + it("pulls a bid down under a lowered budget at any hour", () => { + // $1/day caps at 4 credits; a bid of 10 is over it even at 01:00. + const d = decideBid(input({ bidCredits: 10, dailyBudgetCents: 100, dayFraction: 0.02 })); + expect(d.reason).toBe("capped_by_budget"); + expect(d.bidCredits).toBe(4); + }); + + it("bids the floor with no budget so the campaign still rotates", () => { + const d = decideBid(input({ dailyBudgetCents: 0 })); + expect(d.reason).toBe("no_budget"); + expect(d.bidCredits).toBe(AUTOBID_MIN_CREDITS); + }); + + it("never raises past what the budget covers", () => { + const d = decideBid(input({ bidCredits: 20, spentTodayCents: 0 })); + // 20 is the cap for $5/day; behind pace but nowhere to go. + expect(d.bidCredits).toBe(20); + expect(d.changed).toBe(false); + }); + + it("holds once today's budget is reached instead of chasing it", () => { + const d = decideBid(input({ spentTodayCents: 490 })); + expect(d.reason).toBe("budget_reached"); + expect(d.changed).toBe(false); + }); + + it("raises a campaign winning nothing below the market", () => { + const d = decideBid(input({ impressions24h: 0, marketBidCredits: 8, spentTodayCents: 250 })); + expect(d.reason).toBe("outbid"); + expect(d.bidCredits).toBe(5); + }); + + it("stops raising when already well above the market and still behind", () => { + // Twice the median and still under pace: inventory, not the bid, is short. + const d = decideBid(input({ bidCredits: 8, marketBidCredits: 4, spentTodayCents: 0 })); + expect(d.reason).toBe("inventory_limited"); + expect(d.changed).toBe(false); + }); + + it("carries every input into the signals it records", () => { + const d = decideBid(input({ competitors: 33 })); + expect(d.signals.competitors).toBe(33); + expect(d.signals.maxBidCredits).toBe(20); + }); +}); + +describe("medianBid", () => { + it("is the middle value, or the mean of the two middles", () => { + expect(medianBid([1, 9, 4])).toBe(4); + expect(medianBid([1, 2, 3, 10])).toBe(2.5); + expect(medianBid([])).toBe(0); + }); +}); + +describe("utcDayFraction", () => { + it("is the share of the UTC day elapsed", () => { + expect(utcDayFraction(new Date("2026-09-13T12:00:00Z"))).toBeCloseTo(0.5, 5); + expect(utcDayFraction(new Date("2026-09-13T00:00:00Z"))).toBe(0); + }); +}); + +describe("paper tier", () => { + const TODAY = "2026-09-13"; + const c = (over = {}) => ({ + bid_credits: 4, + daily_budget_cents: 100, + paper_spend_today_cents: 0, + paper_spend_date: TODAY, + ...over, + }); + + it("weights a campaign with paper budget left by its bid", () => { + expect(paperWeight(c(), TODAY)).toBe(4); + expect(paperWeight(c({ bid_credits: 12, daily_budget_cents: 500 }), TODAY)).toBe(12); + }); + + it("drops to a token weight once the paper budget is spent", () => { + // $1 budget, 80c spent, next click at 4 credits is 20c → 100c, which fits. + expect(isPaperBudgetReached(c({ paper_spend_today_cents: 80 }), TODAY)).toBe(false); + // 85c spent: one more click would be 105c. + expect(isPaperBudgetReached(c({ paper_spend_today_cents: 85 }), TODAY)).toBe(true); + expect(paperWeight(c({ paper_spend_today_cents: 85 }), TODAY)).toBe(PAPER_EXHAUSTED_WEIGHT); + }); + + it("forgets yesterday's spend", () => { + expect(paperWeight(c({ paper_spend_today_cents: 999, paper_spend_date: "2026-09-12" }), TODAY)).toBe(4); + }); + + it("uses the token weight with no budget at all", () => { + expect(paperWeight(c({ daily_budget_cents: 0 }), TODAY)).toBe(PAPER_EXHAUSTED_WEIGHT); + }); +}); + +describe("foldBidHistory", () => { + const axis = ["2026-09-10", "2026-09-11", "2026-09-12", "2026-09-13"]; + const row = (day: string, over: Record = {}) => ({ + day, + impressions: 0, + won_bid: null, + clicks: 0, + paper_cents: 0, + spent_cents: 0, + bid: null, + ...over, + }); + + it("carries the bid forward across days that recorded none", () => { + const out = foldBidHistory({ + axis, + days: [row("2026-09-11", { bid: 6 })], + visitsByDay: new Map(), + bidBefore: 4, + currentBid: 6, + }); + expect(out.map((d) => d.bidCredits)).toEqual([4, 6, 6, 6]); + }); + + it("backfills days before the first record with that record", () => { + const out = foldBidHistory({ + axis, + days: [row("2026-09-12", { bid: 5 })], + visitsByDay: new Map(), + bidBefore: null, + currentBid: 5, + }); + expect(out.map((d) => d.bidCredits)).toEqual([5, 5, 5, 5]); + }); + + it("uses the current bid throughout when nothing was ever recorded", () => { + const out = foldBidHistory({ axis, days: [], visitsByDay: new Map(), bidBefore: null, currentBid: 3 }); + expect(out.every((d) => d.bidCredits === 3)).toBe(true); + }); + + it("joins delivery, paper cost and tracked visits onto the same day", () => { + const out = foldBidHistory({ + axis, + days: [row("2026-09-13", { impressions: "120", clicks: 3, paper_cents: 60, won_bid: "4.50" })], + visitsByDay: new Map([["2026-09-13", 2]]), + bidBefore: 4, + currentBid: 4, + }); + const last = out[3]; + expect(last.impressions).toBe(120); + expect(last.clicks).toBe(3); + expect(last.paperCents).toBe(60); + expect(last.visits).toBe(2); + expect(last.wonBidCredits).toBe(4.5); + expect(out[0].visits).toBe(0); + }); +}); + +// A stand-in client that answers the sweep's four reads from fixtures and +// records its writes. The sweep only ever calls from().select()… chains that +// end in a thenable and rpc(), so a small Proxy covers it. +function sweepClient(fixtures: { + campaigns: Record[]; + creatives: Record[]; + activity: Record[]; +}) { + const writes: { table: string; op: string; payload: unknown; filters: unknown[] }[] = []; + const chain = (table: string, op: string, payload: unknown, data: unknown) => { + const filters: unknown[] = []; + const c: any = new Proxy( + {}, + { + get(_t, prop) { + if (prop === "then") { + if (op !== "select") writes.push({ table, op, payload, filters }); + return (res: (v: unknown) => void) => Promise.resolve({ data, error: null }).then(res); + } + return (...args: unknown[]) => { + filters.push([prop, ...args]); + return c; + }; + }, + }, + ); + return c; + }; + const client: any = { + from(table: string) { + return { + select: () => + chain( + table, + "select", + null, + table === "ad_campaigns" ? fixtures.campaigns : table === "ad_creatives" ? fixtures.creatives : [], + ), + update: (payload: unknown) => chain(table, "update", payload, null), + insert: (payload: unknown) => chain(table, "insert", payload, null), + }; + }, + rpc: async () => ({ data: fixtures.activity, error: null }), + }; + return { client, writes }; +} + +describe("runAutobidSweep", () => { + const NOON = new Date("2026-09-13T12:00:00Z"); + const campaign = (id: string, over: Record = {}) => ({ + id, + owner_id: "owner", + status: "active", + autobid: true, + bid_credits: 4, + daily_budget_cents: 500, + spend_today_cents: 0, + spend_date: null, + paper_spend_today_cents: 0, + paper_spend_date: null, + bid_updated_at: null, + ...over, + }); + + it("seeds a first row, then writes the decision and the new bid", async () => { + const { client, writes } = sweepClient({ + campaigns: [campaign("c1")], + creatives: [{ campaign_id: "c1", format: "banner_300x250" }], + activity: [{ campaign_id: "c1", impressions: 50, clicks: 1 }], + }); + const r = await runAutobidSweep(client, NOON); + expect(r).toEqual({ considered: 1, changed: 1, seeded: 1, held: 0 }); + + const update = writes.find((w) => w.table === "ad_campaigns" && w.op === "update"); + expect(update?.payload).toMatchObject({ bid_credits: 5 }); + + const insert = writes.find((w) => w.table === "ad_bids"); + const rows = insert?.payload as Record[]; + expect(rows.map((x) => [x.source, x.bid_credits, x.prev_bid_credits])).toEqual([ + ["seed", 4, null], + ["auto", 5, 4], + ]); + expect((rows[1].signals as Record).competitors).toBe(1); + expect(rows[1].reason).toBe("behind_pace"); + }); + + it("writes nothing for a campaign held within the last day", async () => { + const { client, writes } = sweepClient({ + campaigns: [campaign("c1", { spend_today_cents: 250, spend_date: "2026-09-13", bid_updated_at: "2026-09-13T10:00:00Z" })], + creatives: [], + activity: [], + }); + const r = await runAutobidSweep(client, NOON); + expect(r.held).toBe(1); + expect(writes).toHaveLength(0); + }); + + it("logs a held bid once a day so the chart keeps a point", async () => { + const { client, writes } = sweepClient({ + campaigns: [campaign("c1", { spend_today_cents: 250, spend_date: "2026-09-13", bid_updated_at: "2026-09-11T10:00:00Z" })], + creatives: [], + activity: [], + }); + await runAutobidSweep(client, NOON); + const insert = writes.find((w) => w.table === "ad_bids"); + expect((insert?.payload as Record[])[0]).toMatchObject({ source: "auto", reason: "on_pace", bid_credits: 4 }); + expect(writes.some((w) => w.table === "ad_campaigns" && w.op === "update")).toBe(true); + }); + + it("counts paper spend against the pace like real spend", async () => { + // $5/day, noon: expected $2.50. Paper spend of $2.50 is on pace. + const { client } = sweepClient({ + campaigns: [campaign("c1", { paper_spend_today_cents: 250, paper_spend_date: "2026-09-13", bid_updated_at: "2026-09-13T11:00:00Z" })], + creatives: [], + activity: [], + }); + const r = await runAutobidSweep(client, NOON); + expect(r.changed).toBe(0); + expect(r.held).toBe(1); + }); + + it("finds the market among campaigns sharing a format", async () => { + const { client, writes } = sweepClient({ + campaigns: [ + campaign("c1", { bid_credits: 2, bid_updated_at: "2026-09-13T11:00:00Z" }), + campaign("c2", { bid_credits: 10, bid_updated_at: "2026-09-13T11:00:00Z" }), + campaign("c3", { bid_credits: 10, bid_updated_at: "2026-09-13T11:00:00Z" }), + ], + creatives: [ + { campaign_id: "c1", format: "text_link" }, + { campaign_id: "c2", format: "text_link" }, + { campaign_id: "c3", format: "text_link" }, + ], + // c1 won nothing while bidding under the market: outbid. + activity: [ + { campaign_id: "c2", impressions: 40, clicks: 0 }, + { campaign_id: "c3", impressions: 40, clicks: 0 }, + ], + }); + await runAutobidSweep(client, NOON); + const rows = (writes.find((w) => w.table === "ad_bids")?.payload ?? []) as Record[]; + const c1 = rows.find((x) => x.campaign_id === "c1")!; + expect(c1.reason).toBe("outbid"); + expect((c1.signals as Record).competitors).toBe(3); + expect((c1.signals as Record).marketBidCredits).toBe(10); + }); + + it("reports a failed read and writes nothing", async () => { + const client: any = { + from: () => ({ select: () => ({ in: () => ({ eq: () => ({ limit: async () => ({ data: null, error: { message: "boom" } }) }) }) }) }), + rpc: async () => ({ data: [], error: null }), + }; + const r = await runAutobidSweep(client, NOON); + expect(r.failed).toMatch(/ad_campaigns: boom/); + expect(r.considered).toBe(0); + }); +}); + +describe("credit arithmetic the UI relies on", () => { + it("prices a bid at rack", () => { + expect(4 * CREDIT_CENTS).toBe(20); + }); +}); diff --git a/worker/index.ts b/worker/index.ts index 220146f..ab61d27 100644 --- a/worker/index.ts +++ b/worker/index.ts @@ -47,6 +47,7 @@ import { crawlFeeds } from "../lib/lx/feedCrawl"; import { reapStalePublishingJobs } from "../lib/promote/jobs"; import { ingestDueFeeds } from "../lib/promote/ingest"; import { refreshCookieSessions } from "../lib/sp/sessionRefresh"; +import { runAutobidSweep } from "../lib/ads/bids"; const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; @@ -1525,6 +1526,38 @@ setInterval( FEED_CRAWL_TICK_MS, ); +// Ad autobid. +// +// Every live campaign with autobid on (the default) gets its bid recomputed by +// the pacing controller in lib/ads/autobid.ts: behind its daily pace it bids +// more, ahead it bids less, capped by what the budget could cover. Hourly is +// the right cadence — the controller paces against the fraction of the UTC +// day elapsed, and moving a bid more often than delivery can respond to it +// just writes noise into the history. Runs once at boot too, so a fresh +// deploy has every campaign bidding within a minute rather than an hour. +const AUTOBID_TICK_MS = 60 * 60 * 1000; // 1h +let autobidRunning = false; +async function autobidSweep() { + if (autobidRunning) return; + autobidRunning = true; + try { + const r = await runAutobidSweep(supabase); + if (r.failed) { + console.error(`[worker] autobid sweep failed: ${r.failed}`); + } else if (r.considered > 0) { + console.log( + `[worker] autobid considered=${r.considered} changed=${r.changed} held=${r.held} seeded=${r.seeded}`, + ); + } + } finally { + autobidRunning = false; + } +} +setInterval( + () => autobidSweep().catch((e) => console.error("[worker] autobid sweep", e)), + AUTOBID_TICK_MS, +); + // Bind to loopback by default so the worker isn't reachable from the public // internet when colocated with the app. Override with WORKER_BIND=0.0.0.0 to // run as a separate Railway service. @@ -1538,4 +1571,5 @@ server.listen(port, bindHost, () => { promoteIngestSweep().catch((e) => console.error("[worker] promote ingest", e)); sessionRefreshSweep().catch((e) => console.error("[worker] session refresh sweep", e)); feedCrawlSweep().catch((e) => console.error("[worker] feed crawl sweep", e)); + autobidSweep().catch((e) => console.error("[worker] autobid sweep", e)); });