From fd95fa456872b55d4f5c68d1961c8de4693c2bc1 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sun, 13 Sep 2026 02:22:09 +0000 Subject: [PATCH] Ads: autobid by default, paper-money auction, bid history Every campaign on the network is self-deal, so serveAd demoted every fill to the free tier and the free tier weighted everything at 1: nobody bid, and the auction, pacing and charge path had never run end to end with real numbers. - `autobid` on ad_campaigns, default true for every row. A pure pacing controller (lib/ads/autobid.ts) recomputes each bid hourly from the worker: behind daily pace raises it, ahead lowers it, capped by what the daily budget covers, held when already well above the market. The bid field is gone from the create form; the edit form shows an Autobid switch and the current bid, with a typed bid only when the switch is off. - The free tier is a paper auction: the same bid-weighted lottery, weighted by the paper bid and gated by a paper daily budget. Every free-tier click records what it would have cost (ad_clicks.paper_cents, campaign paper counters) through a new ad_paper_charge RPC that refuses any billed click. No credit balance is ever debited; the paid tier is untouched. - ad_bids logs every decision with its signals. The campaign page charts bid against impressions, clicks and tracker-attributed visits, with the last decisions and their reasons underneath. GET /api/ads/v1/campaigns/[id] carries the same under stats.bid; PATCH takes `autobid`; a bare bid_credits turns autobid off. CLI: `crawlproof ads bid auto|`. Migration 20260913120000_ad_autobid.sql is applied to prod (ref ywcizjsgrcmhgyplldac) and every function was exercised in a rolled-back transaction. The worker seeds every campaign's first bid at boot. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01PDJmt1CPi1fAkpD4FXRSHu --- .../dashboard/ads/[id]/edit/edit-form.tsx | 49 +- app/(app)/dashboard/ads/[id]/edit/page.tsx | 3 +- app/(app)/dashboard/ads/[id]/page.tsx | 66 ++- app/(app)/dashboard/ads/new/form.tsx | 26 +- app/(app)/dashboard/ads/page.tsx | 3 + app/(marketing)/ads/page.tsx | 4 +- app/actions/ads.ts | 47 +- cli/index.ts | 54 ++- components/ads/bid-history.tsx | 233 +++++++++ lib/ads/autobid.ts | 228 +++++++++ lib/ads/bids.ts | 448 ++++++++++++++++++ lib/ads/campaign-request.ts | 15 +- lib/ads/campaigns.ts | 98 +++- lib/ads/serve.ts | 138 +++++- .../migrations/20260913120000_ad_autobid.sql | 270 +++++++++++ tests/ads-api-requests.test.ts | 5 +- tests/ads-autobid-requests.test.ts | 45 ++ tests/ads-autobid.test.ts | 409 ++++++++++++++++ worker/index.ts | 34 ++ 19 files changed, 2097 insertions(+), 78 deletions(-) create mode 100644 components/ads/bid-history.tsx create mode 100644 lib/ads/autobid.ts create mode 100644 lib/ads/bids.ts create mode 100644 supabase/migrations/20260913120000_ad_autobid.sql create mode 100644 tests/ads-autobid-requests.test.ts create mode 100644 tests/ads-autobid.test.ts 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)); });