From 75aa2210170d7e6b4dfe042f7e64c8c27fcfee54 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 11 Sep 2026 21:43:41 +0000 Subject: [PATCH] Run ads where the subject is what people are asking about MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Targeting on what chovy.com's SameBrain says founders are trying to build this week, and the 90 days free that comes with turning it on. Two halves have to agree before a campaign is preferred: its own subject has to be trending AND the page being filled has to be about that subject. Either half alone is how an ad network ends up putting a crypto ad on a recipe blog — one is "this is in the news", the other is ordinary contextual targeting. The page's subject is not new work: the autoblog already keeps a hand-checked subject list per site (lx_site.master_keywords) and that is what is read. It is a preference, not a rule. A match carries four times its own bid weight into the same bid-weighted lottery, so it wins more fills and every other eligible campaign still rotates — the auction's hard requirement. The promo separates two questions that look like one. A campaign inside its ninety days COMPETES for real placement (that is what premium delivery means; parked in the backfill pool it would never serve at all next to a paying advertiser) and BOOKS as tier='free', because nothing is charged. So no spend and no publisher earnings are written for a payment that is not happening — which matters most here, where one account owns both sides of nearly every fill and a paid booking would read as revenue on the ROI dashboard. It is not revenue. It is a discount we gave ourselves. Billing at zero is settled in resolveClick rather than inside ad_charge_click: that function is the hottest SQL in the product and its migrations are applied by hand, so teaching it about promos would put a new failure mode in front of every click on the network. Degrading is the design everywhere it touches. A trend list older than 36 hours stops steering delivery instead of targeting last week forever; a failed pull leaves the stored list; a missing table or column — the migration is applied by hand, so a deploy can lead it — reads as "nobody is trending", which is exactly how serving behaved yesterday. On a network where nobody has opted in this costs one cached query a minute and nothing per fill. $0.02 CPC is stated once, in lib/ads/pricing.ts, and quoted from there by the dashboard, the CLI and the promo row. Known gap recorded there: it is smaller than one credit (5c), and nothing bills a fraction of a credit today, so after a promo ends a trending campaign bills at its ordinary bid until sub-credit metering exists. The migration is added, not applied — apply it one file at a time. SAMEBRAIN_URL and SAMEBRAIN_SECRET go in the vault and on the deployment, never in a committed .env file. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01QuUkTrofSjQ15j79mRuy4f --- .env.example | 9 + README.md | 32 ++ app/(app)/dashboard/ads/[id]/page.tsx | 29 ++ app/(app)/dashboard/ads/new/form.tsx | 20 + app/actions/ads.ts | 31 +- app/api/ads/v1/campaigns/[id]/route.ts | 10 +- app/api/ads/v1/campaigns/route.ts | 6 +- app/api/ads/v1/trends/route.ts | 59 +++ app/api/cron/ad-trends/route.ts | 44 +++ cli/index.ts | 108 +++++- lib/ads/campaign-request.ts | 34 +- lib/ads/campaigns.ts | 136 ++++++- lib/ads/pricing.ts | 19 + lib/ads/promos.ts | 158 ++++++++ lib/ads/serve.ts | 117 +++++- lib/ads/trendContext.ts | 157 ++++++++ lib/ads/trending.ts | 354 ++++++++++++++++++ lib/ads/trends.ts | 267 +++++++++++++ lib/env.ts | 7 + .../20260911120000_ad_trend_targeting.sql | 122 ++++++ tests/ads-trending.test.ts | 289 ++++++++++++++ tests/contract/ads-trend-serving.test.ts | 262 +++++++++++++ 22 files changed, 2237 insertions(+), 33 deletions(-) create mode 100644 app/api/ads/v1/trends/route.ts create mode 100644 app/api/cron/ad-trends/route.ts create mode 100644 lib/ads/promos.ts create mode 100644 lib/ads/trendContext.ts create mode 100644 lib/ads/trending.ts create mode 100644 lib/ads/trends.ts create mode 100644 supabase/migrations/20260911120000_ad_trend_targeting.sql create mode 100644 tests/ads-trending.test.ts create mode 100644 tests/contract/ads-trend-serving.test.ts diff --git a/.env.example b/.env.example index f97bf824..d3c18a1e 100644 --- a/.env.example +++ b/.env.example @@ -59,6 +59,15 @@ WORKER_SHARED_SECRET=change-me-to-a-long-random-string # Cron (Vercel cron will pass this header) CRON_SECRET=change-me-to-a-long-random-string +# SameBrain — the trend source the ad network targets on (chovy.com). The +# secret is the same value that service has; put both in the vault +# (logicsrc team `crawlproof-com--prod`) and set them on the deployment, never +# in a committed .env file. Unset means no trend ingestion, which degrades to +# ordinary untargeted delivery rather than to an error. +# Ingest hourly: POST /api/cron/ad-trends with the cron secret. +SAMEBRAIN_URL=https://chovy.com +SAMEBRAIN_SECRET= + # Paid scan engines # Backend text generation for Autoblog/profile enrichment: # auto = Anthropic when available, otherwise OpenAI diff --git a/README.md b/README.md index 4f91fb0c..0bbba213 100644 --- a/README.md +++ b/README.md @@ -173,6 +173,38 @@ The endpoints underneath are the same ones anything else can call: | `GET /api/ads/v1/earnings?days=` | ad delivery and money, both sides | | `GET/POST /api/ads/v1/campaigns` | list and run campaigns | | `GET/POST /api/ads/v1/slots` | list and create publisher slots | +| `GET /api/ads/v1/trends` | what is trending, and how old the list is | + +### Trending-topic targeting + +An advertiser can run where the subject is what people are asking about right +now: + +```sh +crawlproof ads trends +crawlproof ads create https://example.com/launch --trending +crawlproof ads trending crawlproof-ad-144 on +``` + +Two halves have to agree before a campaign is preferred: its own subject has to +be trending **and** the page being filled has to be about that subject. Either +half alone is how an ad network ends up putting a crypto ad on a recipe blog. +The page's subject comes from what CrawlProof already knows about the site — +the autoblog's `lx_site.master_keywords`, its niche, the slot's niche. + +The signals come from **SameBrain** on chovy.com (what founders are asking to +build this week), pulled hourly by `POST /api/cron/ad-trends` with the cron +secret and stored in `ad_trend_topics`. A list older than 36 hours stops +steering delivery entirely rather than targeting last week's subjects forever, +and a failed pull is never an outage: delivery falls back to the ordinary +auction. + +Turning it on grants **90 days** during which the campaign serves and meters +exactly as usual and every click is billed at **$0.00** (`ad_promos`; the rate +afterwards is $0.02/click). A promo fill competes for real placement but books +under `tier='free'`, because nothing is charged — so no spend and no publisher +earnings are written for a payment that did not happen, and on a network where +one account owns both sides it can never read as revenue. ## Product flows diff --git a/app/(app)/dashboard/ads/[id]/page.tsx b/app/(app)/dashboard/ads/[id]/page.tsx index 5ccd057d..38a7a5b2 100644 --- a/app/(app)/dashboard/ads/[id]/page.tsx +++ b/app/(app)/dashboard/ads/[id]/page.tsx @@ -7,6 +7,8 @@ import { CampaignActions, RegenerateButton } from "@/components/ads/campaign-act import { CampaignTrend } from "@/components/ads/campaign-trend"; import { getCampaignDailySeries } from "@/lib/ads/series"; import { campaignDisplayStatus, spendTodayCents, utcToday } from "@/lib/ads/status"; +import { promoStateForCampaign } from "@/lib/ads/promos"; +import { TRENDING_CPC_CENTS } from "@/lib/ads/pricing"; export const metadata = { title: "Campaign" }; @@ -79,6 +81,11 @@ export default async function CampaignDetailPage({ .maybeSingle(), ]); + // The promo is its own read: it lives in ad_promos, and the row may not + // exist at all (nobody has enabled trending targeting, or the migration has + // not been applied here yet). Both read as "no promo". + const promo = await promoStateForCampaign(supabase, id); + const impressions = (stats?.impressions as number) ?? 0; const clicks = (stats?.clicks as number) ?? 0; const freeImpressions = (stats?.free_impressions as number) ?? 0; @@ -152,6 +159,28 @@ export default async function CampaignDetailPage({

)} + {/* The 90 days. Shown whether or not it is still running, because + "your clicks started costing money last Tuesday" is the single most + useful thing this page can say to somebody on the promo. */} + {promo.endsAt && ( +

+ {promo.active ? ( + <> + Trending promo: + {promo.daysRemaining} day{promo.daysRemaining === 1 ? "" : "s"} left. Clicks are + billed at $0.00 until{" "} + {promo.endsAt.slice(0, 10)}, then{" "} + ${(TRENDING_CPC_CENTS / 100).toFixed(2)} per click. + + ) : ( + <> + Trending promo ended + {promo.endsAt.slice(0, 10)}. Clicks bill normally. + + )} +

+ )} +
diff --git a/app/(app)/dashboard/ads/new/form.tsx b/app/(app)/dashboard/ads/new/form.tsx index f737486d..66701440 100644 --- a/app/(app)/dashboard/ads/new/form.tsx +++ b/app/(app)/dashboard/ads/new/form.tsx @@ -19,6 +19,9 @@ export function NewAdForm() { const [budget, setBudget] = useState(5); // dollars/day const [bid, setBid] = useState(0.2); // dollars/click (max 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. + const [trending, setTrending] = useState(false); const [brand, setBrand] = useState(null); const [creatives, setCreatives] = useState([]); const [active, setActive] = useState("banner_300x250"); @@ -111,6 +114,7 @@ export function NewAdForm() { bidCredits: Math.max(1, Math.round((bid * 100) / 5)), brand, creatives, + trendingTopics: trending, }); if (!res.ok) { setError(res.error); @@ -182,6 +186,22 @@ export function NewAdForm() { {generating ? "Designing ads…" : hasAds ? "Regenerate" : "Generate ads"}
+ {error && ( diff --git a/app/actions/ads.ts b/app/actions/ads.ts index 41753a1f..2b44bbde 100644 --- a/app/actions/ads.ts +++ b/app/actions/ads.ts @@ -16,6 +16,8 @@ import { } from "@/lib/ads/creative"; import type { SiteBrand } from "@/lib/ads/brand"; import { MIN_PAYOUT_CENTS, DEFAULT_BID_CREDITS } from "@/lib/ads/pricing"; +import { cleanTopics } from "@/lib/ads/trending"; +import { grantTrendingPromo } from "@/lib/ads/promos"; import { createCryptoPayout } from "@/lib/coinpay"; const ASSET_BUCKET = "ad-assets"; @@ -149,7 +151,9 @@ export async function saveCampaign(input: { brand?: SiteBrand | null; creatives: Partial[]; summary?: Partial | null; -}): Promise<{ ok: true; id: string; refSlug: string } | { ok: false; error: string }> { + /** Prefer this campaign where its subject is trending, and take the 90 days. */ + trendingTopics?: boolean; +}): Promise<{ ok: true; id: string; refSlug: string; promoDays?: number } | { ok: false; error: string }> { const supabase = await createClient(); const { data: { user }, @@ -185,6 +189,15 @@ export async function saveCampaign(input: { brand: input.brand ?? {}, }; if (org.id) payload.organization_id = org.id; + // 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 + // gamed, and this form has the page in front of it already. + if (input.trendingTopics) { + payload.trending_topics = true; + const topics = cleanTopics([input.brand?.title, input.brand?.description, domainOf(check.url).split(".")[0]]); + if (topics.length) payload.topics = topics; + } // Editorial prose for placements that live inside content. Only stored when // it actually describes where the campaign points: the user can edit the URL @@ -217,8 +230,10 @@ 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_|schema cache|column/i.test(campaign.error.message ?? "")) { + if (campaign.error && /summary_|trending_topics|topics|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; campaign = await supabase .from("ad_campaigns") .insert(payload) @@ -249,8 +264,18 @@ export async function saveCampaign(input: { const { error: cErr } = await supabase.from("ad_creatives").insert(rows); if (cErr) return { ok: false, error: cErr.message }; + // Turning trending targeting on is what earns the 90 days, and the grant is + // idempotent — a campaign saved twice does not get a second window. A + // failure to grant never fails the save: the campaign runs and bills + // normally, which the detail page shows by having no promo line at all. + let promoDays: number | undefined; + if (input.trendingTopics) { + const granted = await grantTrendingPromo(supabase, { userId: user.id, campaignId: campaign.data.id, note: "trending targeting enabled in the dashboard" }); + promoDays = granted.state.active ? granted.state.daysRemaining : undefined; + } + revalidatePath("/dashboard/ads"); - return { ok: true, id: campaign.data.id, refSlug: campaign.data.ref_slug }; + return { ok: true, id: campaign.data.id, refSlug: campaign.data.ref_slug, promoDays }; } // --- Campaign editing (advertiser) --- diff --git a/app/api/ads/v1/campaigns/[id]/route.ts b/app/api/ads/v1/campaigns/[id]/route.ts index 04cea3ac..e95e4262 100644 --- a/app/api/ads/v1/campaigns/[id]/route.ts +++ b/app/api/ads/v1/campaigns/[id]/route.ts @@ -3,8 +3,9 @@ // GET the campaign and its delivery: impressions, clicks, spend, and the // visits the tracker attributed to it (bucket ad:) on the // caller's own sites. -// PATCH { name?, daily_budget_cents?, bid_credits?, status? } +// PATCH { name?, daily_budget_cents?, bid_credits?, status?, trending_topics?, topics? } // status is active | paused | draft. Going active needs a creative. +// trending_topics true is also what grants the 90-day promo, once. // DELETE removes it, metering included. Pause keeps the history. // // Same auth as the collection route. This is what `crawlproof ads show|pause| @@ -13,7 +14,7 @@ import { NextResponse, type NextRequest } from "next/server"; import { serviceClient } from "@/lib/supabase/service"; import { authenticateBearer } from "@/lib/sp/apiAuth"; -import { campaignStats, deleteCampaign, findCampaign, parseCampaignPatch, patchCampaign } from "@/lib/ads/campaigns"; +import { campaignStats, deleteCampaign, findCampaign, parseCampaignPatch, patchCampaign, withTargeting } from "@/lib/ads/campaigns"; import { env } from "@/lib/env"; export const runtime = "nodejs"; @@ -38,7 +39,10 @@ export async function GET(req: NextRequest, ctx: Ctx) { const loaded = await load(req, ctx); if ("error" in loaded) return loaded.error; const stats = await campaignStats(loaded.sb, loaded.userId, loaded.campaign); - return NextResponse.json({ ...withUrl(loaded.campaign), stats }); + // Targeting and the promo come from their own reads; see withTargeting for + // why they are not columns on the campaign select. + const campaign = await withTargeting(loaded.sb, loaded.campaign); + return NextResponse.json({ ...withUrl(campaign), stats }); } export async function PATCH(req: NextRequest, ctx: Ctx) { diff --git a/app/api/ads/v1/campaigns/route.ts b/app/api/ads/v1/campaigns/route.ts index 3ed13408..5ea19c63 100644 --- a/app/api/ads/v1/campaigns/route.ts +++ b/app/api/ads/v1/campaigns/route.ts @@ -1,9 +1,13 @@ // /api/ads/v1/campaigns — campaigns for a bearer-token caller. // -// POST { url, name?, daily_budget_cents?, bid_credits?, status? } +// POST { url, name?, daily_budget_cents?, bid_credits?, status?, +// trending_topics?, topics? } // Read the page, write the creatives, save the campaign. Active unless // status is "draft". A live campaign for the same URL is returned // instead of a twin, with `existing: true`. +// `trending_topics: true` opts into trending-topic targeting and grants +// the 90-day premium promo — see lib/ads/promos.ts. Topics default to +// what the landing page is about. // GET ?limit=20 // The caller's campaigns, newest first. // diff --git a/app/api/ads/v1/trends/route.ts b/app/api/ads/v1/trends/route.ts new file mode 100644 index 00000000..dff2bd8f --- /dev/null +++ b/app/api/ads/v1/trends/route.ts @@ -0,0 +1,59 @@ +// /api/ads/v1/trends — what is trending, for advertisers choosing targeting. +// +// GET ?window=7&limit=50&stale=1 +// The current signals, newest ingest, highest score first. `stale=1` +// includes a list too old to steer delivery, which is how the CLI can +// explain why nothing is being boosted rather than showing an empty +// page that looks like a bug. +// +// Same bearer auth as the rest of /api/ads/v1: `Authorization: Bearer crp_…`. +// Ingestion is not here — it runs on a schedule and is gated on the cron +// secret (app/api/cron/ad-trends). + +import { NextResponse, type NextRequest } from "next/server"; +import { serviceClient } from "@/lib/supabase/service"; +import { authenticateBearer } from "@/lib/sp/apiAuth"; +import { currentTrends } from "@/lib/ads/trends"; +import { TREND_MAX_AGE_HOURS, TREND_SOURCE, TREND_WINDOW_DAYS, trendsAreStale } from "@/lib/ads/trending"; +import { TRENDING_CPC_CENTS } from "@/lib/ads/pricing"; +import { PROMO_DAYS } from "@/lib/ads/trending"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function GET(req: NextRequest) { + const auth = await authenticateBearer(req); + if (!auth.ok) return NextResponse.json({ error: auth.error }, { status: auth.status }); + + const params = new URL(req.url).searchParams; + const windowDays = Number(params.get("window")) || TREND_WINDOW_DAYS; + const limit = Number(params.get("limit")) || 50; + const includeStale = params.get("stale") === "1" || params.get("stale") === "true"; + + const now = Date.now(); + const signals = await currentTrends(serviceClient(), { windowDays, limit, includeStale, now }); + const newest = signals.reduce( + (latest, signal) => (!latest || (signal.ingestedAt ?? "") > latest ? (signal.ingestedAt ?? latest) : latest), + null, + ); + + return NextResponse.json({ + source: TREND_SOURCE, + window_days: windowDays, + ingested_at: newest, + // A list this old no longer steers delivery; saying so is the difference + // between "nothing is trending" and "the puller has been down since + // Tuesday", which look identical from the outside. + stale: trendsAreStale(newest, now), + max_age_hours: TREND_MAX_AGE_HOURS, + promo: { kind: "trending_premium_90", days: PROMO_DAYS, cpc_cents: TRENDING_CPC_CENTS }, + topics: signals.map((signal) => ({ + topic: signal.topic, + score: signal.score, + mentions: signal.mentions, + prior_mentions: signal.priorMentions, + generated_at: signal.generatedAt, + ingested_at: signal.ingestedAt, + })), + }); +} diff --git a/app/api/cron/ad-trends/route.ts b/app/api/cron/ad-trends/route.ts new file mode 100644 index 00000000..73f11963 --- /dev/null +++ b/app/api/cron/ad-trends/route.ts @@ -0,0 +1,44 @@ +// Pull the trend list in. Run it hourly. +// +// Server to server in both directions: this route is gated on CRON_SECRET the +// same way every other cron route is, and it presents SAMEBRAIN_SECRET to the +// source. Neither secret belongs in a committed .env file — both are set on +// the Railway service and kept in the vault (logicsrc team +// `crawlproof-com--prod`). +// +// A failed pull is not an outage. Serving keeps using the stored list until it +// ages out (TREND_MAX_AGE_HOURS), and after that every campaign simply falls +// back to its ordinary auction weight. + +import { NextResponse } from "next/server"; +import { serviceClient } from "@/lib/supabase/service"; +import { env } from "@/lib/env"; +import { ingestTrends } from "@/lib/ads/trends"; +import { TREND_WINDOW_DAYS } from "@/lib/ads/trending"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function GET(req: Request) { + return POST(req); +} + +export async function POST(req: Request) { + const incoming = + req.headers.get("x-cron-secret") ?? + req.headers.get("authorization")?.replace(/^Bearer\s+/i, ""); + // No cron secret configured means this route is closed, not open: it writes + // the targeting signals every fill reads. + if (!env.cronSecret || incoming !== env.cronSecret) { + return NextResponse.json({ ok: false, error: "unauthorized" }, { status: 401 }); + } + + const windowDays = Number(new URL(req.url).searchParams.get("window")) || TREND_WINDOW_DAYS; + const result = await ingestTrends(serviceClient(), { + url: env.samebrainUrl, + secret: env.samebrainSecret, + windowDays, + }); + if (!result.ok) return NextResponse.json({ ok: false, error: result.error }, { status: result.status }); + return NextResponse.json(result); +} diff --git a/cli/index.ts b/cli/index.ts index a98c31bf..9ea518cb 100644 --- a/cli/index.ts +++ b/cli/index.ts @@ -249,10 +249,26 @@ export function campaignBodyFromArgs(args: Args): Record { if (typeof args.flags.name === "string") body.name = args.flags.name; if (typeof args.flags.budget === "string") body.daily_budget_cents = Number(args.flags.budget); if (typeof args.flags.bid === "string") body.bid_credits = Number(args.flags.bid); + // --trending both turns the targeting on and is what earns the 90 days. + if (args.flags.trending) body.trending_topics = true; + if (typeof args.flags.topics === "string") body.topics = args.flags.topics.split(",").map((topic) => topic.trim()); body.status = args.flags.draft ? "draft" : "active"; return body; } +/** One line about a campaign's promo, or nothing at all. Pure, for tests. */ +export function promoLine(promo: unknown): string { + const state = (promo ?? null) as { active?: boolean; daysRemaining?: number; endsAt?: string | null; cpcCents?: number } | null; + if (!state) return ""; + if (state.active) { + const days = Number(state.daysRemaining) || 0; + const rate = Number(state.cpcCents) ? ` (then $${(Number(state.cpcCents) / 100).toFixed(2)}/click)` : ""; + return ` promo: ${days} day${days === 1 ? "" : "s"} left, clicks billed at $0.00${rate}\n`; + } + const ended = state.endsAt ? ` (ended ${String(state.endsAt).slice(0, 10)})` : ""; + return ` promo: over${ended}, clicks bill normally\n`; +} + /** The request body `crawlproof slots create` sends, from its flags. Pure, for tests. */ export function slotBodyFromArgs(args: Args): Record { const body: Record = { site: args.positional[1] }; @@ -268,7 +284,7 @@ async function cmdAds(args: Args): Promise { const sub = args.positional[0]; if (sub === "create") { if (!args.positional[1]) { - console.error("usage: crawlproof ads create [--name=N] [--budget=CENTS] [--bid=CREDITS] [--draft] [--json]"); + console.error("usage: crawlproof ads create [--name=N] [--budget=CENTS] [--bid=CREDITS] [--trending] [--topics=a,b] [--draft] [--json]"); return 2; } const { status, json } = await apiCall(args, "POST", "/api/ads/v1/campaigns", campaignBodyFromArgs(args)); @@ -284,6 +300,64 @@ async function cmdAds(args: Args): Promise { } return 0; } + if (sub === "trends") { + const windowDays = (args.flags.window as string | undefined) ?? "7"; + const limit = (args.flags.limit as string | undefined) ?? "20"; + const stale = args.flags.stale ? "&stale=1" : ""; + const { status, json } = await apiCall(args, "GET", `/api/ads/v1/trends?window=${encodeURIComponent(windowDays)}&limit=${encodeURIComponent(limit)}${stale}`); + if (status >= 400) { + console.error(`ads trends failed: ${status} ${json.error ?? ""}`); + return 1; + } + if (args.flags.json) { + process.stdout.write(`${JSON.stringify(json, null, 2)}\n`); + return 0; + } + const topics = (json.topics as Record[]) ?? []; + const promo = (json.promo ?? {}) as { days?: number; cpc_cents?: number }; + // A stale list steers nothing, and saying so is the difference between + // "nothing is trending" and "the puller has been down since Tuesday". + if (json.stale) { + process.stdout.write( + `The trend list is stale (last ingest ${json.ingested_at ?? "never"}); nothing is being boosted on it.\n`, + ); + } + if (!topics.length) process.stdout.write("No trending topics stored.\n"); + for (const topic of topics) { + process.stdout.write( + `${String(topic.score).padStart(8)} ${String(topic.topic).padEnd(28)} ${topic.mentions} mention(s), was ${topic.prior_mentions}\n`, + ); + } + if (topics.length) { + process.stdout.write( + `\nTarget these with: crawlproof ads create --trending (${promo.days ?? 90} days free, then $${((promo.cpc_cents ?? 2) / 100).toFixed(2)}/click)\n`, + ); + } + return 0; + } + if (sub === "trending") { + const ref = args.positional[1]; + const want = (args.positional[2] ?? "on").toLowerCase(); + if (!ref || !["on", "off"].includes(want)) { + console.error("usage: crawlproof ads trending on|off [--topics=a,b]"); + return 2; + } + const body: Record = { trending_topics: want === "on" }; + if (typeof args.flags.topics === "string") body.topics = args.flags.topics.split(",").map((topic) => topic.trim()); + const { status, json } = await apiCall(args, "PATCH", `/api/ads/v1/campaigns/${encodeURIComponent(ref)}`, body); + if (status >= 400) { + console.error(`ads trending failed: ${status} ${json.error ?? ""}`); + return 1; + } + if (args.flags.json) { + process.stdout.write(`${JSON.stringify(json, null, 2)}\n`); + return 0; + } + const topics = (json.topics as string[]) ?? []; + process.stdout.write(`${json.ref_slug} trending targeting ${json.trending_topics ? "on" : "off"}${topics.length ? ` — ${topics.join(", ")}` : ""}\n`); + process.stdout.write(promoLine(json.promo)); + return 0; + } if (sub === "show" || sub === "pause" || sub === "resume" || sub === "budget" || sub === "delete") { const ref = args.positional[1]; if (!ref) { @@ -325,6 +399,11 @@ async function cmdAds(args: Args): Promise { } 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`); + if (json.trending_topics) { + const topics = (json.topics as string[]) ?? []; + process.stdout.write(` trending targeting on${topics.length ? ` — ${topics.join(", ")}` : ""}\n`); + } + process.stdout.write(promoLine(json.promo)); if (stats) { const visits = stats.visits as { total: number } | undefined; process.stdout.write( @@ -347,11 +426,13 @@ async function cmdAds(args: Args): Promise { } if (!campaigns.length) process.stdout.write("No campaigns yet.\n"); for (const c of campaigns) { - process.stdout.write(`${String(c.status).padEnd(8)} ${String(c.ref_slug).padEnd(20)} ${c.name} ${c.destination_url}\n`); + const promo = (c.promo ?? null) as { active?: boolean; daysRemaining?: number } | null; + const mark = c.trending_topics ? (promo?.active ? ` [trending · ${promo.daysRemaining}d free]` : " [trending]") : ""; + process.stdout.write(`${String(c.status).padEnd(8)} ${String(c.ref_slug).padEnd(20)} ${c.name} ${c.destination_url}${mark}\n`); } return 0; } - console.error(`unknown: crawlproof ads ${sub} (expected: create | list | show | pause | resume | budget | delete)`); + console.error(`unknown: crawlproof ads ${sub} (expected: create | list | show | pause | resume | budget | delete | trending | trends)`); return 2; } @@ -540,18 +621,33 @@ COMMANDS Defaults --event to "pageview". Project id can also come from CRAWLPROOF_PROJECT. Override host with CRAWLPROOF_SITE_URL. - ads create [--name=N] [--budget=CENTS] [--bid=CREDITS] [--draft] [--json] + ads create [--name=N] [--budget=CENTS] [--bid=CREDITS] [--trending] + [--topics=a,b] [--draft] [--json] Run an ad campaign for a URL: CrawlProof reads the page, writes the creatives and starts serving (active unless --draft). A URL that already has a live campaign gets that campaign back. Needs an API token (CRAWLPROOF_TOKEN, from Social → API tokens). + --trending prefers this campaign on pages whose subject is what + people are asking about right now, and earns 90 days during which + every click is billed at $0.00 (the rate after that is $0.02/click). + + ads trends [--window=7] [--limit=20] [--stale] [--json] + What is trending, highest score first, with how many separate + parties used each subject this window and last. --stale shows a + list too old to steer delivery instead of hiding it. + + ads trending on|off [--topics=a,b] [--json] + Turn trending targeting on or off for a campaign. Turning it on is + what grants the 90 days, once; turning it off later does not take + the remaining days away. ads list [--limit=20] [--json] Your campaigns, newest first. ads show [--json] - One campaign with its delivery: impressions, clicks, spend, and the - visits the tracker attributed to it on your own sites. + One campaign with its delivery: impressions, clicks, spend, the + visits the tracker attributed to it on your own sites, and the days + left on its promo. ads pause | ads resume | ads budget Change a campaign in place. A ref looks like crawlproof-ad-144. diff --git a/lib/ads/campaign-request.ts b/lib/ads/campaign-request.ts index 1965f0c2..c2555893 100644 --- a/lib/ads/campaign-request.ts +++ b/lib/ads/campaign-request.ts @@ -5,6 +5,7 @@ // by tests and could be by a client. import { isAllowedTargetUrl } from "@/lib/rateLimit"; +import { cleanTopics } from "@/lib/ads/trending"; export type CampaignStatus = "active" | "draft"; @@ -14,8 +15,27 @@ export type CampaignRequest = { dailyBudgetCents?: number; bidCredits?: number; status?: CampaignStatus; + /** Prefer this campaign where its subject is what people are asking about. */ + trendingTopics?: boolean; + /** The subjects it is about. Derived from the page when the caller says nothing. */ + topics?: string[]; }; +/** + * A boolean as somebody typed it. + * + * `--trending` from a shell arrives as the string "true", a JSON caller sends + * a real boolean, and a form sends "on". Anything else is not a yes. + */ +function asBoolean(value: unknown): boolean | undefined { + if (value === undefined || value === null) return undefined; + if (typeof value === "boolean") return value; + const text = String(value).trim().toLowerCase(); + if (["true", "1", "yes", "on"].includes(text)) return true; + if (["false", "0", "no", "off", ""].includes(text)) return false; + return undefined; +} + export function domainOf(url: string): string { try { return new URL(url).hostname.replace(/^www\./, ""); @@ -53,6 +73,11 @@ export function parseCampaignRequest(body: Record): { ok: true; request.bidCredits = Math.min(200, Math.round(n)); } if (statusRaw === "active" || statusRaw === "draft") request.status = statusRaw; + + const trending = asBoolean(body.trending_topics ?? body.trendingTopics ?? body.trending); + if (trending !== undefined) request.trendingTopics = trending; + const topics = cleanTopics(body.topics); + if (topics.length) request.topics = topics; return { ok: true, request, url: check.url }; } @@ -61,6 +86,8 @@ export type CampaignPatch = { dailyBudgetCents?: number; bidCredits?: number; status?: "active" | "paused" | "draft"; + trendingTopics?: boolean; + topics?: string[]; }; /** Pure: a PATCH body, normalised with the dashboard's clamps. Empty is an error. */ @@ -88,7 +115,12 @@ export function parseCampaignPatch(body: Record): { ok: true; p } patch.status = body.status; } - if (!Object.keys(patch).length) return { ok: false, error: "Nothing to change: send name, daily_budget_cents, bid_credits or status." }; + const trending = asBoolean(body.trending_topics ?? body.trendingTopics ?? body.trending); + 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: true, patch }; } diff --git a/lib/ads/campaigns.ts b/lib/ads/campaigns.ts index d2c0e221..ce7ef8d9 100644 --- a/lib/ads/campaigns.ts +++ b/lib/ads/campaigns.ts @@ -19,6 +19,8 @@ import { getOrCreateDefaultOrg } from "@/lib/orgs"; import { generateAdCreatives, cleanSummary, creativesFromCopy, templateCopy, summaryDomain, type AdCreative, type AdSummary } from "@/lib/ads/creative"; 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"; export type CampaignSummary = { id: string; @@ -33,6 +35,12 @@ export type CampaignSummary = { dashboard_url?: string; /** True when a live campaign for this URL already existed and was returned instead. */ existing?: boolean; + /** Opted into trending-topic targeting. */ + trending_topics?: boolean; + /** The subjects this campaign is about. */ + topics?: string[]; + /** The 90-day premium promo, when there is one. */ + promo?: PromoState & { kind: string } | null; }; export type CampaignResult = @@ -76,7 +84,55 @@ function summaryColumns(summary: AdSummary | null | undefined, domain: string): }; } -const schemaLag = (message: string | undefined) => /organization_id|summary_|schema cache|column/i.test(message ?? ""); +const schemaLag = (message: string | undefined) => /organization_id|summary_|trending_topics|topics|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 isOptionalColumn = (key: string) => OPTIONAL_COLUMNS.includes(key) || key.startsWith("summary_"); + +/** + * Trending targeting and promo state for campaigns, read separately. + * + * A separate query rather than two more columns on every campaign select, for + * the same reason `campaignSummary` is separate in lib/ads/serve.ts: these + * columns ride behind a migration applied by hand, and a select naming a + * column that does not exist yet returns nothing at all — which would empty + * the campaign list rather than hide one field of it. + */ +async function targetingFor( + sb: SupabaseClient, + 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, 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 ?? []) }); + } + } catch { + return out; + } + return out; +} + +/** A campaign with its targeting and its promo attached, for an API answer. */ +export async function withTargeting( + sb: SupabaseClient, + campaign: CampaignSummary, + now = Date.now(), +): Promise { + const targeting = (await targetingFor(sb, [campaign.id])).get(campaign.id); + const promo = targeting?.trending ? promoState(await promoForCampaign(sb, campaign.id), now) : null; + return { + ...campaign, + trending_topics: targeting?.trending ?? false, + topics: targeting?.topics ?? [], + promo: promo && promo.startsAt ? { ...promo, kind: "trending_premium_90" } : null, + }; +} export async function createCampaignForUrl(input: { sb: SupabaseClient; @@ -106,14 +162,26 @@ export async function createCampaignForUrl(input: { const { error } = await sb.from("ad_campaigns").update({ status: "active" }).eq("id", twin.id).eq("owner_id", userId); if (!error) current = "active"; } + // Asking for trending targeting on a URL that already has a campaign turns + // it on there rather than being ignored — the caller asked for a state, + // not for a new row. The promo grant is idempotent, so a second call + // re-reads the first ninety days instead of starting another. + if (request.trendingTopics) { + const { error } = await sb + .from("ad_campaigns") + .update({ trending_topics: true, ...(request.topics?.length ? { topics: request.topics } : {}) }) + .eq("id", twin.id) + .eq("owner_id", userId); + if (!error) await grantTrendingPromo(sb, { userId, campaignId: twin.id as string, note: "trending targeting enabled on an existing campaign" }); + } return { ok: true, - campaign: { + campaign: await withTargeting(sb, { ...(twin as CampaignSummary), status: current, existing: true, dashboard_url: `${input.siteUrl}/dashboard/ads/${twin.id}`, - }, + }), }; } @@ -151,13 +219,21 @@ export async function createCampaignForUrl(input: { ...summaryColumns(generated.summary, domain), }; if (org.id) payload.organization_id = org.id; + if (request.trendingTopics) payload.trending_topics = true; + // 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. + const topics = request.topics?.length + ? request.topics + : cleanTopics([generated.brand?.title, generated.brand?.description, domain.split(".")[0]]); + if (topics.length) payload.topics = topics; const select = "id, ref_slug, name, status, destination_url, daily_budget_cents, bid_credits, created_at"; let inserted = await sb.from("ad_campaigns").insert(payload).select(select).single(); // Migrations here are applied by hand, so a deploy can run ahead of the // schema; the optional columns are dropped rather than refusing the campaign. if (inserted.error && schemaLag(inserted.error.message)) { - for (const key of Object.keys(payload)) if (key === "organization_id" || key.startsWith("summary_")) delete payload[key]; + for (const key of Object.keys(payload)) if (isOptionalColumn(key)) delete payload[key]; inserted = await sb.from("ad_campaigns").insert(payload).select(select).single(); } if (inserted.error || !inserted.data) { @@ -174,13 +250,21 @@ export async function createCampaignForUrl(input: { return { ok: false, status: 500, error: creativeError.message }; } + // Turning trending targeting on is what earns the 90 days. Granted after the + // campaign exists so the entitlement can name it, and a failure to grant + // never fails the campaign: the ads still run, they simply bill normally, + // and the dashboard shows no promo rather than a promo that is not there. + if (request.trendingTopics) { + await grantTrendingPromo(sb, { userId, campaignId: campaign.id, note: "trending targeting enabled at creation" }); + } + return { ok: true, - campaign: { + campaign: await withTargeting(sb, { ...campaign, creatives: generated.creatives.length, dashboard_url: `${input.siteUrl}/dashboard/ads/${campaign.id}`, - }, + }), }; } @@ -191,7 +275,24 @@ export async function listCampaigns(input: { sb: SupabaseClient; userId: string; .eq("owner_id", input.userId) .order("created_at", { ascending: false }) .limit(Math.min(200, Math.max(1, input.limit))); - return ((data as CampaignSummary[]) ?? []).map((c) => ({ ...c, dashboard_url: `${input.siteUrl}/dashboard/ads/${c.id}` })); + const campaigns = ((data as CampaignSummary[]) ?? []).map((c) => ({ ...c, dashboard_url: `${input.siteUrl}/dashboard/ads/${c.id}` })); + + // Two extra reads for the whole page, not two per campaign: the targeting + // columns in one query, then the promos of whichever campaigns opted in. + const targeting = await targetingFor(input.sb, campaigns.map((c) => c.id)); + const trendingIds = campaigns.filter((c) => targeting.get(c.id)?.trending).map((c) => c.id); + const promos = trendingIds.length ? await promosForCampaigns(input.sb, trendingIds) : new Map(); + const now = Date.now(); + return campaigns.map((campaign) => { + const own = targeting.get(campaign.id); + const state = own?.trending ? promoState(promos.get(campaign.id) ?? null, now) : null; + return { + ...campaign, + trending_topics: own?.trending ?? false, + topics: own?.topics ?? [], + promo: state && state.startsAt ? { ...state, kind: "trending_premium_90" } : null, + }; + }); } // ------------------------------------------------------------ one campaign @@ -272,9 +373,26 @@ export async function patchCampaign( } update.status = patch.status; } - const { data, error } = await sb.from("ad_campaigns").update(update).eq("id", campaign.id).eq("owner_id", userId).select(CAMPAIGN_COLUMNS).single(); + if (patch.trendingTopics !== undefined) update.trending_topics = patch.trendingTopics; + if (patch.topics !== undefined) update.topics = patch.topics; + + 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)) { + 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." }; + ({ 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." }; - return { ok: true, campaign: data as CampaignSummary }; + + // 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. + if (patch.trendingTopics === true) { + await grantTrendingPromo(sb, { userId, campaignId: campaign.id, note: "trending targeting enabled" }); + } + return { ok: true, campaign: await withTargeting(sb, data as CampaignSummary) }; } /** Delete outright. Impressions and clicks cascade with it; pausing keeps them. */ diff --git a/lib/ads/pricing.ts b/lib/ads/pricing.ts index 35a73fa3..62eee32e 100644 --- a/lib/ads/pricing.ts +++ b/lib/ads/pricing.ts @@ -25,6 +25,25 @@ export const CPC_CREDITS = 4; export const DEFAULT_BID_CREDITS = CPC_CREDITS; export const CPC_CENTS = CPC_CREDITS * CREDIT_CENTS; +// Trending-topic premium: the rate a trending-targeted click is quoted at, +// in CENTS, and the rate the 90-day promo is free of. Two cents, stated here +// rather than in a marketing page so the dashboard, the CLI and the promo row +// all quote the same number. +// +// It is deliberately expressed in cents and not in credits, because it is +// SMALLER than one credit: a credit is 5c, so $0.02 is 0.4 of one. Nothing +// bills a fraction of a credit today — ad_charge_click moves whole integer +// credits — so a campaign at this rate cannot be metered through the credit +// path once its promo ends. `trendingCpcCredits()` is what that would round +// to; until sub-credit metering exists, a trending campaign after its promo +// bills at the ordinary bid instead, and the gap is a known one rather than a +// silent one. +export const TRENDING_CPC_CENTS = 2; + +export function trendingCpcCredits(): number { + return TRENDING_CPC_CENTS / CREDIT_CENTS; +} + // Platform take rate; the rest accrues to the publisher (at the floor rate). export const PLATFORM_RATE = 0.3; diff --git a/lib/ads/promos.ts b/lib/ads/promos.ts new file mode 100644 index 00000000..09df540b --- /dev/null +++ b/lib/ads/promos.ts @@ -0,0 +1,158 @@ +// The 90-day trending promo, as rows. +// +// An advertiser who turns trending targeting on gets ninety days during which +// their campaign serves and meters exactly as it always would and every click +// is charged nothing. The entitlement is a row rather than a flag with a date +// on the campaign, because "was this click free?" has to stay answerable after +// the promo ends, after the campaign is edited, and after the price changes. +// +// Granting is idempotent and never extends: a second opt-in reads the first +// promo back. Ninety days free is ninety days, not ninety per toggle. + +import type { SupabaseClient } from "@supabase/supabase-js"; +import { TRENDING_CPC_CENTS } from "./pricing"; +import { PROMO_KIND, promoState, promoWindow, type Promo, type PromoState } from "./trending"; + +type PromoRow = { + id: string; + owner_id: string; + campaign_id: string | null; + kind: string; + cpc_cents: number | null; + starts_at: string; + ends_at: string; + revoked_at: string | null; +}; + +const COLUMNS = "id, owner_id, campaign_id, kind, cpc_cents, starts_at, ends_at, revoked_at"; + +const rowToPromo = (row: PromoRow): Promo => ({ + id: row.id, + ownerId: row.owner_id, + campaignId: row.campaign_id, + kind: row.kind, + cpcCents: Number(row.cpc_cents) || 0, + startsAt: row.starts_at, + endsAt: row.ends_at, + revokedAt: row.revoked_at, +}); + +/** + * The live promo for a campaign, or null. + * + * Every failure reads as "no promo": the table rides behind a migration + * applied by hand, and a missing table must mean an advertiser is billed + * normally, never that everybody's clicks become free. + */ +export async function promoForCampaign( + sb: SupabaseClient, + campaignId: string, +): Promise { + if (!campaignId || campaignId === "house") return null; + try { + const { data, error } = await sb + .from("ad_promos") + .select(COLUMNS) + .eq("campaign_id", campaignId) + .eq("kind", PROMO_KIND) + .is("revoked_at", null) + .order("ends_at", { ascending: false }) + .limit(1) + .maybeSingle(); + if (error || !data) return null; + return rowToPromo(data as PromoRow); + } catch { + return null; + } +} + +/** Live promos for several campaigns at once, keyed by campaign id. Serving path. */ +export async function promosForCampaigns( + sb: SupabaseClient, + 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_promos") + .select(COLUMNS) + .in("campaign_id", ids) + .eq("kind", PROMO_KIND) + .is("revoked_at", null); + if (error || !data) return out; + for (const row of data as PromoRow[]) { + if (row.campaign_id) out.set(row.campaign_id, rowToPromo(row)); + } + } catch { + return out; + } + return out; +} + +/** Where a campaign's promo stands, ready for a dashboard or a CLI line. */ +export async function promoStateForCampaign( + sb: SupabaseClient, + campaignId: string, + now = Date.now(), +): Promise { + return promoState(await promoForCampaign(sb, campaignId), now); +} + +export type GrantResult = { + promo: Promo | null; + state: PromoState; + /** True when this call is what created the promo. */ + granted: boolean; +}; + +/** + * Give a campaign its ninety days, once. + * + * Called when trending targeting is turned on. An existing promo — live or + * expired — is returned untouched: re-granting on every toggle would make the + * promo infinite, and quietly restarting an expired one would give a campaign + * a second ninety days nobody agreed to. + * + * A failure to write is not a failure to save the campaign. The advertiser + * asked for trending targeting; the promo is what we owe them for it, and if + * the row cannot be written the campaign still targets and simply bills + * normally. That is visible — the dashboard shows no promo — rather than + * silent. + */ +export async function grantTrendingPromo( + sb: SupabaseClient, + input: { userId: string; campaignId: string; now?: number; note?: string }, +): Promise { + const now = input.now ?? Date.now(); + const existing = await promoForCampaign(sb, input.campaignId); + if (existing) return { promo: existing, state: promoState(existing, now), granted: false }; + + const { startsAt, endsAt } = promoWindow(now); + try { + const { data, error } = await sb + .from("ad_promos") + .insert({ + owner_id: input.userId, + campaign_id: input.campaignId, + kind: PROMO_KIND, + cpc_cents: TRENDING_CPC_CENTS, + starts_at: startsAt, + ends_at: endsAt, + note: (input.note ?? "").slice(0, 200), + }) + .select(COLUMNS) + .single(); + if (error || !data) { + // Another request may have granted it a moment ago; the unique index is + // the arbiter, so read rather than assume. + const again = await promoForCampaign(sb, input.campaignId); + return { promo: again, state: promoState(again, now), granted: false }; + } + const promo = rowToPromo(data as PromoRow); + return { promo, state: promoState(promo, now), granted: true }; + } catch { + return { promo: null, state: promoState(null, now), granted: false }; + } +} diff --git a/lib/ads/serve.ts b/lib/ads/serve.ts index 5eaeddff..1eb21ed3 100644 --- a/lib/ads/serve.ts +++ b/lib/ads/serve.ts @@ -23,6 +23,10 @@ import { import { hashIpRotating, rotatingIpHashCandidates } from "@/lib/ipHash"; import { runAuction } from "./auction"; import { generateShortCode } from "./shortcode"; +import { competesForPaid, fillTier, matchTrend, trendWeight, type TrendMatch } from "./trending"; +import { promoActiveFor, trendContextFor } from "./trendContext"; +import { promoForCampaign } from "./promos"; +import { promoState, clickChargeCents } from "./trending"; // Server-side ad selection + metering. Runs under the service-role client so // the public serving endpoints can read cross-tenant campaigns/creatives and @@ -52,6 +56,13 @@ export type Fill = { /** ASCII rendering of the same creative, for terminal/MOTD consumers. */ text: string; tier: AdTier; + /** + * The trending subjects this fill was chosen for, if it was. Empty on every + * ordinary fill, which is most of them. + */ + trendTopics?: string[]; + /** True when the advertiser's 90-day promo is what made this click free. */ + promo?: boolean; }; export function isAdFormat(v: string | null | undefined): v is AdFormatId { @@ -177,9 +188,12 @@ export async function serveAd( ): Promise { const sb = serviceClient(); + // `project_id` and `niche` are original columns on ad_slots, so naming them + // here cannot be what a hand-applied migration has not caught up with. They + // are what tells us what the page being filled is about. const { data: slot } = await sb .from("ad_slots") - .select("id, status, formats, owner_id, theme") + .select("id, status, formats, owner_id, theme, project_id, niche") .eq("id", slotId) .maybeSingle(); if (!slot || slot.status !== "active") return null; @@ -264,8 +278,26 @@ export async function serveAd( ]), ); + // 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( + sb, + slot as { project_id?: string | null; niche?: string | null }, + candidates.map((row) => oneCampaign(row.ad_campaigns).id), + ); + const matches = new Map(); + const matchFor = (campaignId: string): TrendMatch => { + if (!trend.any) return { topics: [], matched: false, score: 0 }; + const cached = matches.get(campaignId); + if (cached) return cached; + const computed = matchTrend(trend.optIns.get(campaignId) ?? [], trend.page, trend.trends); + matches.set(campaignId, computed); + return computed; + }; + const paid: Row[] = []; const free: Row[] = []; + const tierByCampaign = new Map(); for (const row of candidates) { const c = oneCampaign(row.ad_campaigns); const bid = c.bid_credits ?? DEFAULT_BID_CREDITS; @@ -275,11 +307,23 @@ export async function serveAd( // Same owner on both sides of the transaction: the click can't be billed, // so it must never win paid inventory ahead of an advertiser who would // actually pay. Free tier is exactly the right home for it — same place a - // campaign that has run out of funds goes. - const isSelfDeal = !!(slot.owner_id && c.owner_id === slot.owner_id); + // campaign that has run out of funds goes. A campaign inside its 90-day + // promo lands there for the same reason: nothing is being charged, so + // nothing can be earned, and booking it as paid would write spend and + // publisher earnings that never happened. // Legacy 'exhausted' rows never compete for paid inventory on that status // alone — funds decide, and a top-up puts them straight back in the auction. - (hasBudget && hasFunds && !isSelfDeal ? paid : free).push(row); + const money = { + selfDeal: !!(slot.owner_id && c.owner_id === slot.owner_id), + promoActive: trend.any && promoActiveFor(trend, c.id), + hasBudget, + hasFunds, + }; + // Two separate questions: which pool this competes in, and what the + // impression books as. A promo campaign competes for the placement it was + // promised and books under a tier that can never move money. + tierByCampaign.set(c.id, fillTier(money)); + (competesForPaid(money) ? paid : free).push(row); } // Paid inventory first, always. Free-tier campaigns only ever fill requests no @@ -294,26 +338,42 @@ export async function serveAd( if (Math.random() < HOUSE_AD_ROTATION_RATE) return houseFill(format, theme); // Bid-weighted lottery: every eligible campaign can win, with probability - // proportional to its bid, so all active ads rotate (higher bids more often). + // proportional to its bid, so all active ads rotate (higher bids more + // often). A campaign whose subject is trending AND is what this page is + // about carries a multiple of its own weight — a preference, not a rule, + // so everything else still rotates. pick = runAuction( paid.map((row) => ({ - bidCredits: oneCampaign(row.ad_campaigns).bid_credits ?? DEFAULT_BID_CREDITS, + bidCredits: trendWeight( + oneCampaign(row.ad_campaigns).bid_credits ?? DEFAULT_BID_CREDITS, + matchFor(oneCampaign(row.ad_campaigns).id), + ), item: row, })), )?.winner; } // Nothing paid to show: backfill with a real advertiser's ad instead of the - // house ad. Uniform pick, not bid-weighted — nobody is paying, so a high bid - // buys no priority here. + // 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. if (!pick && free.length > 0) { tier = "free"; - pick = free[Math.floor(Math.random() * free.length)]; + pick = runAuction( + free.map((row) => ({ + bidCredits: trendWeight(1, matchFor(oneCampaign(row.ad_campaigns).id)), + item: row, + })), + )?.winner; } if (!pick) return houseFill(format, theme); const campaign = oneCampaign(pick.ad_campaigns); if (!campaign) return null; + // The winner's own answer wins over which pool it came from: a promo + // campaign can win the paid auction and must still book as free. + tier = tierByCampaign.get(campaign.id) ?? tier; // Record the impression first so we have an id to bind the click to. const ipHash = hashIpRotating(ctx.ip ?? null); @@ -390,6 +450,8 @@ export async function serveAd( html: renderCreativeHtml(creative, clickUrl, { theme }), text: renderCreativeText(creative, clickUrl), tier, + trendTopics: matchFor(campaign.id).topics, + promo: trend.any && promoActiveFor(trend, campaign.id), }; } @@ -506,7 +568,42 @@ export async function resolveClick(input: { device: input.ctx?.device, }); - if (validity.valid) { + // The 90-day promo, settled here rather than inside ad_charge_click. + // + // The charge function is the hottest piece of SQL in the product and its + // migrations are applied by hand; teaching it about promos would put a + // whole new failure mode in front of every click on the network. Doing it + // here keeps that function untouched, and the rule is simple enough to + // read in one sitting: a promo click is a real click, recorded exactly + // like any other, billed at nothing. + // + // It books as `tier='free'` — the bucket that already means "real + // delivery, nobody could be charged" — so it shows on the dashboard as + // delivery and never as spend. And because nothing is charged, nothing is + // accrued to the publisher either: paying a publisher out of a payment + // that is not happening is how a promo turns into a cash loss, and on this + // network (where the same account owns both sides of nearly every fill) it + // would also read as revenue on the ROI dashboard. It is not revenue. + const promo = promoState(await promoForCampaign(sb, campaign.id)); + 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", + }); + } 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", { diff --git a/lib/ads/trendContext.ts b/lib/ads/trendContext.ts new file mode 100644 index 00000000..1f53bbd0 --- /dev/null +++ b/lib/ads/trendContext.ts @@ -0,0 +1,157 @@ +// What serving needs to know about trends, cheaply enough to ask on every fill. +// +// Three facts are needed to prefer a trending-targeted campaign: which +// campaigns opted in and what subjects they claim, what is trending, and what +// the page being filled is about. Asked naively that is three extra queries on +// the hottest path in the product. +// +// So the two that are the same for everybody — the opt-ins and the trend list +// — are cached in the module for a minute, and the third is only asked at all +// once we know a candidate opted in. On a network where nobody has enabled +// trending targeting, this costs one small cached query per minute and nothing +// per fill. +// +// Every failure here reads as "no trending campaigns", which is exactly how +// serving behaved before this existed. A trend list is a preference; nothing +// about it is worth failing a fill over. + +import type { SupabaseClient } from "@supabase/supabase-js"; +import { currentTrends } from "./trends"; +import { cleanTopics, pageTopics, type TrendSignal } from "./trending"; +import { promosForCampaigns } from "./promos"; +import { promoState, type Promo } from "./trending"; + +/** How long the shared facts are held. A minute is invisible to a 90-day promo. */ +export const CACHE_MS = 60_000; + +type Cached = { at: number; value: T } | null; + +let optInCache: Cached> = null; +let trendCache: Cached = null; + +/** Tests and long-lived processes that change the data underneath us. */ +export function resetTrendCaches(): void { + optInCache = null; + trendCache = null; +} + +/** + * Campaigns that opted into trending targeting, and the subjects they claim. + * + * One query against the partial index on `trending_topics`, so on a network + * with none it reads nothing at all. A missing column (the migration is + * applied by hand, so a deploy can lead it) is an empty map. + */ +export async function trendingCampaigns( + sb: SupabaseClient, + now = Date.now(), +): Promise> { + if (optInCache && now - optInCache.at < CACHE_MS) return optInCache.value; + const map = new Map(); + try { + const { data, error } = await sb + .from("ad_campaigns") + .select("id, topics") + .eq("trending_topics", true) + .in("status", ["active", "exhausted"]) + .limit(500); + if (!error && data) { + for (const row of data as { id: string; topics: string[] | null }[]) { + map.set(row.id, cleanTopics(row.topics ?? [])); + } + } + } catch { + // Fall through to the empty map. + } + optInCache = { at: now, value: map }; + return map; +} + +/** The current trend list, cached for the same minute. */ +export async function trendSignals(sb: SupabaseClient, now = Date.now()): Promise { + if (trendCache && now - trendCache.at < CACHE_MS) return trendCache.value; + const signals = await currentTrends(sb, { now }); + trendCache = { at: now, value: signals }; + return signals; +} + +/** + * What the page being filled is about. + * + * Read from what CrawlProof already knows about the publisher's site: the + * autoblog's hand-checked subject list for it, the niche that list came from, + * and the slot's own niche. Not cached — it is per-slot and only asked when a + * trending-targeted campaign is actually in the running. + */ +export async function topicsForSlot( + sb: SupabaseClient, + slot: { project_id?: string | null; niche?: string | null }, +): Promise { + const slotNiche = slot.niche ?? null; + if (!slot.project_id) return pageTopics({ slotNiche }); + try { + const [{ data: site }, { data: project }] = await Promise.all([ + sb.from("lx_site").select("master_keywords, niche").eq("project_id", slot.project_id).maybeSingle(), + sb.from("projects").select("name").eq("id", slot.project_id).maybeSingle(), + ]); + return pageTopics({ + masterKeywords: (site as { master_keywords?: string[] | null } | null)?.master_keywords ?? null, + niche: (site as { niche?: string | null } | null)?.niche ?? null, + slotNiche, + projectName: (project as { name?: string | null } | null)?.name ?? null, + }); + } catch { + return pageTopics({ slotNiche }); + } +} + +export type TrendContext = { + /** Campaign id → the subjects it claims. Empty when nobody opted in. */ + optIns: Map; + trends: TrendSignal[]; + /** What the slot's page is about. Empty until somebody opted in. */ + page: string[]; + /** Campaign id → its live promo, if any. */ + promos: Map; + /** True when some candidate opted in, so the rest of this is worth reading. */ + any: boolean; +}; + +export const EMPTY_CONTEXT: TrendContext = { + optIns: new Map(), + trends: [], + page: [], + promos: new Map(), + any: false, +}; + +/** + * Everything the fill needs, or nothing. + * + * `candidateIds` are the campaigns already in the running for this fill. If + * none of them opted in, this stops after the cached opt-in read — no trend + * query, no page query, no promo query. + */ +export async function trendContextFor( + sb: SupabaseClient, + slot: { project_id?: string | null; niche?: string | null }, + candidateIds: string[], + now = Date.now(), +): Promise { + const optIns = await trendingCampaigns(sb, now); + if (!optIns.size) return EMPTY_CONTEXT; + const relevant = candidateIds.filter((id) => optIns.has(id)); + if (!relevant.length) return EMPTY_CONTEXT; + + const [trends, page, promos] = await Promise.all([ + trendSignals(sb, now), + topicsForSlot(sb, slot), + promosForCampaigns(sb, relevant), + ]); + return { optIns, trends, page, promos, any: true }; +} + +/** Is this campaign's promo running right now? */ +export function promoActiveFor(context: TrendContext, campaignId: string, now = Date.now()): boolean { + return promoState(context.promos.get(campaignId) ?? null, now).active; +} diff --git a/lib/ads/trending.ts b/lib/ads/trending.ts new file mode 100644 index 00000000..9457dff9 --- /dev/null +++ b/lib/ads/trending.ts @@ -0,0 +1,354 @@ +// Trending-topic targeting. +// +// An advertiser can say "run my ads where the subject is what people are +// asking about right now". Two halves have to agree before that means +// anything: the campaign's own subjects have to be trending, AND the page +// being filled has to be about one of them. Either half alone is how ad +// networks end up putting crypto ads on a recipe blog because crypto was in +// the news. +// +// The trend list comes from outside (lib/ads/trends.ts pulls it); the page's +// subject comes from what CrawlProof already knows about the site — its +// autoblog master keywords, its niche, the slot's own niche. Nothing here +// reaches a database or a network: it is the matching and the money rules, so +// both can be tested without either. + +import { DEFAULT_BID_CREDITS } from "./pricing"; + +/** The only trend source today: chovy.com's SameBrain read on what founders are building. */ +export const TREND_SOURCE = "samebrain"; + +/** Default window to target on, matching what the source ranks over. */ +export const TREND_WINDOW_DAYS = 7; + +/** + * How stale a trend list may be and still steer delivery. + * + * A pull runs hourly; a day of failed pulls is a list describing last week, + * and the honest thing to do with it is stop targeting on it rather than keep + * preferring yesterday's subjects forever. Past this age every campaign falls + * back to its ordinary auction weight, so a broken ingest degrades to the + * behaviour that existed before this feature. + */ +export const TREND_MAX_AGE_HOURS = 36; + +/** + * How much a trending match is worth in the auction, as a multiplier on the + * campaign's own bid weight. + * + * Deliberately a preference, not a rule: a matched campaign wins more often, + * every other eligible campaign still rotates. "All ads rotate" is a hard + * requirement of the auction (see lib/ads/auction.ts) and a targeting feature + * that silently starved everything else would break it. + */ +export const TREND_MATCH_MULTIPLIER = 4; + +export type TrendSignal = { + source: string; + topic: string; + score: number; + mentions: number; + priorMentions: number; + windowDays: number; + generatedAt: string | null; + ingestedAt: string | null; +}; + +// ------------------------------------------------------------------ terms + +/** + * Light stemming, so "recipes" and "recipe" are one subject. + * + * One character off `-es` by default and two only after a sibilant: "codes" is + * "code", "boxes" is "box". Always taking two turns "codes" into "cod" and + * every plural quietly stops matching its singular — the autoblog stemmer had + * exactly that bug. + */ +export function stem(word: string): string { + const value = String(word || ""); + if (value.length <= 3) return value; + if (value.endsWith("ies") && value.length > 4) return `${value.slice(0, -3)}y`; + if (value.endsWith("es")) { + const before = value.slice(0, -2); + return /(s|x|z|ch|sh)$/.test(before) ? before : value.slice(0, -1); + } + if (value.endsWith("s") && !value.endsWith("ss")) return value.slice(0, -1); + return value; +} + +/** + * A topic as it is stored and compared: lowercase, stemmed word by word, + * punctuation gone. "Dog Walking!" and "dog walkings" are the same subject. + */ +export function normalizeTopic(topic: string): string { + return String(topic || "") + .toLowerCase() + .replace(/[^a-z0-9+#\s-]+/g, " ") + .split(/[\s-]+/) + .filter((word) => word.length >= 2 && word.length <= 24) + .map((word) => stem(word)) + .join(" ") + .trim(); +} + +/** Normalised, deduplicated, and capped. What goes into `ad_campaigns.topics`. */ +export function cleanTopics(input: unknown, max = 12): string[] { + const raw = Array.isArray(input) + ? input + : typeof input === "string" + ? input.split(",") + : []; + const out: string[] = []; + for (const item of raw) { + const topic = normalizeTopic(String(item)); + if (!topic) continue; + if (!out.includes(topic)) out.push(topic); + if (out.length >= max) break; + } + return out; +} + +/** + * The subjects a page or a site is about, from whatever CrawlProof knows. + * + * The autoblog already answers this question for every site it writes for — + * `lx_site.master_keywords` is a hand-checked 3-12 subject list and `niche` is + * the sentence it was derived from. The slot may carry its own niche, and the + * project's name is the last resort. Anything is better than treating an + * unknown page as matching everything, which is the failure mode that puts an + * ad for payroll software on a fishing blog. + */ +export function pageTopics(input: { + masterKeywords?: string[] | null; + niche?: string | null; + slotNiche?: string | null; + projectName?: string | null; +}): string[] { + const parts = [ + ...(input.masterKeywords ?? []), + ...(input.slotNiche ? [input.slotNiche] : []), + ...(input.niche ? [input.niche] : []), + ...(input.projectName ? [input.projectName] : []), + ]; + return cleanTopics(parts, 24); +} + +/** + * Do two subject lists mean the same thing? + * + * A match is an exact normalised equality, or one being a whole-word phrase + * inside the other: "dog walking" matches "dog walking roster", and "payment" + * matches "payment link". Substring matching without the word boundary is how + * "art" matches "smart", which is the classic version of this mistake. + */ +export function topicsIntersect(left: string[], right: string[]): string[] { + const found: string[] = []; + for (const a of left) { + for (const b of right) { + if (!a || !b) continue; + const contains = + a === b || + ` ${a} `.includes(` ${b} `) || + ` ${b} `.includes(` ${a} `); + if (contains && !found.includes(a)) found.push(a); + } + } + return found; +} + +export type TrendMatch = { + /** The campaign's subjects that are both trending and on this page. */ + topics: string[]; + matched: boolean; + /** Summed trend score of the matched subjects, for reporting. */ + score: number; +}; + +/** + * Does this campaign belong on this page right now? + * + * Both halves, deliberately: the campaign's subject has to be trending AND the + * page has to be about it. A campaign about a trending subject on an unrelated + * page is the thing publishers complain about, and a campaign matching the + * page but on no trend is just ordinary contextual targeting, which this + * feature is not claiming to be. + */ +export function matchTrend( + campaignTopics: string[], + page: string[], + trends: TrendSignal[], +): TrendMatch { + if (!campaignTopics.length || !page.length || !trends.length) { + return { topics: [], matched: false, score: 0 }; + } + const trendTopics = trends.map((t) => normalizeTopic(t.topic)).filter(Boolean); + const trending = topicsIntersect(campaignTopics, trendTopics); + if (!trending.length) return { topics: [], matched: false, score: 0 }; + const onPage = topicsIntersect(trending, page); + if (!onPage.length) return { topics: [], matched: false, score: 0 }; + + let score = 0; + for (const topic of onPage) { + for (const signal of trends) { + if (topicsIntersect([topic], [normalizeTopic(signal.topic)]).length) { + score += Number(signal.score) || 0; + } + } + } + return { topics: onPage, matched: true, score: Math.round(score * 100) / 100 }; +} + +/** Auction weight for a candidate: its bid, lifted while it is a trending match. */ +export function trendWeight(bidCredits: number | null | undefined, match: TrendMatch): number { + const bid = Number(bidCredits) > 0 ? Number(bidCredits) : DEFAULT_BID_CREDITS; + return match.matched ? bid * TREND_MATCH_MULTIPLIER : bid; +} + +/** A trend list old enough to be describing a different week. */ +export function trendsAreStale(ingestedAt: string | null | undefined, now = Date.now()): boolean { + if (!ingestedAt) return true; + const at = Date.parse(ingestedAt); + if (!Number.isFinite(at)) return true; + return now - at > TREND_MAX_AGE_HOURS * 60 * 60 * 1000; +} + +// ------------------------------------------------------------------ promo + +/** The one promo kind: trending targeting on, premium delivery, ninety days free. */ +export const PROMO_KIND = "trending_premium_90"; +export const PROMO_DAYS = 90; + +export type Promo = { + id?: string; + ownerId?: string; + campaignId?: string | null; + kind?: string; + cpcCents?: number; + startsAt: string; + endsAt: string; + revokedAt?: string | null; +}; + +export type PromoState = { + active: boolean; + daysRemaining: number; + startsAt: string | null; + endsAt: string | null; + /** The rate the advertiser pays once it ends, in cents per click. */ + cpcCents: number; +}; + +export const NO_PROMO: PromoState = { + active: false, + daysRemaining: 0, + startsAt: null, + endsAt: null, + cpcCents: 0, +}; + +/** The window a promo granted now would cover. */ +export function promoWindow(startedAt: Date | number = Date.now()): { startsAt: string; endsAt: string } { + const start = startedAt instanceof Date ? startedAt : new Date(startedAt); + const end = new Date(start.getTime() + PROMO_DAYS * 24 * 60 * 60 * 1000); + return { startsAt: start.toISOString(), endsAt: end.toISOString() }; +} + +/** + * Where a promo stands, right now. + * + * Days remaining is rounded UP, so the last partial day still reads as "1 day + * left" rather than "0" to somebody whose ads are demonstrably still free. It + * reaches 0 only once the promo is actually over. + */ +export function promoState(promo: Promo | null | undefined, now: number = Date.now()): PromoState { + if (!promo) return NO_PROMO; + const start = Date.parse(promo.startsAt); + const end = Date.parse(promo.endsAt); + if (!Number.isFinite(start) || !Number.isFinite(end)) return NO_PROMO; + const revoked = promo.revokedAt ? Date.parse(promo.revokedAt) : NaN; + const endsAtMs = Number.isFinite(revoked) ? Math.min(end, revoked) : end; + const active = now >= start && now < endsAtMs; + return { + active, + daysRemaining: active ? Math.max(1, Math.ceil((endsAtMs - now) / (24 * 60 * 60 * 1000))) : 0, + startsAt: new Date(start).toISOString(), + endsAt: new Date(endsAtMs).toISOString(), + cpcCents: Number(promo.cpcCents) || 0, + }; +} + +/** + * What a click costs, in cents. + * + * Zero while the promo runs. This is the whole promo: the ad serves, the + * impression is recorded, the click is recorded, and the advertiser is charged + * nothing. Metering is deliberately unchanged — a promo that also stopped + * counting would leave the advertiser with ninety days of ads and no evidence + * they ran. + */ +export function clickChargeCents(input: { promo: PromoState; cpcCents: number }): number { + if (input.promo.active) return 0; + return Math.max(0, Math.round(input.cpcCents)); +} + +/** + * Which campaigns compete for the inventory a paying advertiser wants. + * + * Money and delivery are two different questions, and conflating them was a + * bug: a promo campaign books no money (see fillTier below), but it is a + * PREMIUM placement — that is what was promised — so it has to compete in the + * real auction rather than sit in the backfill pool where it would only ever + * fill requests nobody else wanted. On a network with paying advertisers a + * promo campaign would otherwise never serve at all. + * + * It competes without funds on purpose: an advertiser inside their ninety days + * is not spending credits, so requiring a balance would make the promo + * conditional on the thing it exists to waive. + * + * Self-deal still never competes for paid inventory: that rule is about not + * displacing an advertiser who would actually pay, and it predates all of this. + */ +export function competesForPaid(input: { + selfDeal: boolean; + promoActive: boolean; + hasBudget: boolean; + hasFunds: boolean; +}): boolean { + if (input.selfDeal) return false; + if (input.promoActive) return true; + return input.hasBudget && input.hasFunds; +} + +/** + * Which tier a fill books under. + * + * 'paid' is the only tier that can move money, and three separate situations + * must never reach it: + * + * * self-deal — the same account owns the slot and the campaign, so there is + * no money to move and ad_charge_click refuses to bill it anyway. It gets + * the free tier rather than being dropped, because dropping self-owned + * campaigns once removed 100% of this network's inventory (PR #177). + * * promo — the advertiser is not being charged, so the publisher cannot be + * paid out of a payment that is not happening. Free tier is what "real + * delivery, no money" already means here. + * * out of budget or out of credit — the existing rule, unchanged. + * + * The promo case matters most on THIS network, where every slot and every + * campaign belong to one account: a promo that booked as paid would write + * spend and publisher earnings on both sides of the same pocket and read as + * revenue on the ROI dashboard. It is not revenue. It is a discount we gave + * ourselves, and it books as free. + * + * This is about the money only. Which campaigns compete for the placement is + * `competesForPaid` above, and a promo campaign competes. + */ +export function fillTier(input: { + selfDeal: boolean; + promoActive: boolean; + hasBudget: boolean; + hasFunds: boolean; +}): "paid" | "free" { + if (input.selfDeal || input.promoActive) return "free"; + return input.hasBudget && input.hasFunds ? "paid" : "free"; +} diff --git a/lib/ads/trends.ts b/lib/ads/trends.ts new file mode 100644 index 00000000..7e3e9e6e --- /dev/null +++ b/lib/ads/trends.ts @@ -0,0 +1,267 @@ +// Pulling the trend list in, and reading it back out. +// +// chovy.com's SameBrain endpoint answers "what are founders trying to build +// this week" as a list of subjects with a score. This module fetches that over +// a shared secret, normalises it, and stores it as targeting signals. Serving +// reads `currentTrends`; nothing on the serving path ever reaches chovy.com. +// +// The secret lives in the vault (logicsrc team `crawlproof-com--prod`) as +// SAMEBRAIN_SECRET and is set as an environment variable on the Railway +// service — never in a committed .env file. + +import type { SupabaseClient } from "@supabase/supabase-js"; +import { + TREND_SOURCE, + TREND_WINDOW_DAYS, + normalizeTopic, + trendsAreStale, + type TrendSignal, +} from "./trending"; + +export type IngestResult = + | { ok: true; source: string; windowDays: number; stored: number; removed: number; generatedAt: string | null } + | { ok: false; status: number; error: string }; + +const clampNumber = (value: unknown, min: number, max: number, fallback: number): number => { + const n = Number(value); + if (!Number.isFinite(n)) return fallback; + return Math.min(max, Math.max(min, n)); +}; + +/** + * What the source sent, turned into rows we are willing to store. + * + * Written defensively on purpose: this is the one place another service's JSON + * reaches our database, and a trend list is not worth a single unchecked + * value. Topics that normalise to nothing are dropped rather than stored as + * empty strings that would then match every page. + */ +export function parseTrendPayload( + payload: unknown, + options: { windowDays?: number; limit?: number } = {}, +): { ok: true; signals: TrendSignal[]; generatedAt: string | null } | { ok: false; error: string } { + const body = (payload ?? {}) as Record; + const rawTopics = body.topics; + if (!Array.isArray(rawTopics)) return { ok: false, error: "The trend source answered without a topics array." }; + + const source = typeof body.source === "string" && body.source.trim() ? body.source.trim().slice(0, 32) : TREND_SOURCE; + const windowDays = clampNumber(body.window_days ?? options.windowDays, 1, 90, TREND_WINDOW_DAYS); + const generatedAtRaw = body.generated_at; + const generatedAt = + typeof generatedAtRaw === "number" && Number.isFinite(generatedAtRaw) + ? new Date(generatedAtRaw).toISOString() + : typeof generatedAtRaw === "string" && Number.isFinite(Date.parse(generatedAtRaw)) + ? new Date(Date.parse(generatedAtRaw)).toISOString() + : null; + + const limit = clampNumber(options.limit, 1, 200, 50); + const seen = new Set(); + const signals: TrendSignal[] = []; + for (const item of rawTopics) { + const row = (item ?? {}) as Record; + const topic = normalizeTopic(String(row.topic ?? "")); + if (!topic || seen.has(topic)) continue; + seen.add(topic); + signals.push({ + source, + topic, + score: clampNumber(row.score, 0, 1_000_000, 0), + mentions: Math.round(clampNumber(row.count ?? row.mentions, 0, 1_000_000, 0)), + priorMentions: Math.round(clampNumber(row.prior_count ?? row.priorMentions, 0, 1_000_000, 0)), + windowDays: Math.round(clampNumber(row.window_days ?? windowDays, 1, 90, windowDays)), + generatedAt, + ingestedAt: null, + }); + if (signals.length >= limit) break; + } + return { ok: true, signals, generatedAt }; +} + +export type TrendSourceConfig = { + /** Base URL of the trend source, e.g. https://chovy.com */ + url: string; + secret: string; + windowDays?: number; + limit?: number; +}; + +/** + * Ask the source what is trending. + * + * Server to server, bearer secret, short timeout: this runs on a schedule and + * a trend list is never worth holding a request open for. A failure here is + * not an outage — serving falls back to the stored list, and past + * TREND_MAX_AGE_HOURS to no trend targeting at all. + */ +export async function fetchTrends( + config: TrendSourceConfig, + fetchImpl: typeof fetch = fetch, +): Promise<{ ok: true; signals: TrendSignal[]; generatedAt: string | null } | { ok: false; status: number; error: string }> { + if (!config.url) return { ok: false, status: 503, error: "No trend source configured (SAMEBRAIN_URL)." }; + if (!config.secret) return { ok: false, status: 503, error: "No trend source secret configured (SAMEBRAIN_SECRET)." }; + + const windowDays = Math.round(clampNumber(config.windowDays, 1, 90, TREND_WINDOW_DAYS)); + const limit = Math.round(clampNumber(config.limit, 1, 200, 50)); + const base = config.url.replace(/\/$/, ""); + const url = `${base}/api/samebrain/trending?window=${windowDays}&limit=${limit}`; + + let response: Response; + try { + response = await fetchImpl(url, { + headers: { authorization: `Bearer ${config.secret}`, accept: "application/json" }, + signal: AbortSignal.timeout(15_000), + }); + } catch (error) { + return { ok: false, status: 502, error: error instanceof Error ? error.message : "The trend source could not be reached." }; + } + if (!response.ok) { + return { ok: false, status: response.status, error: `The trend source answered ${response.status}.` }; + } + + let payload: unknown; + try { + payload = await response.json(); + } catch { + return { ok: false, status: 502, error: "The trend source answered with something that is not JSON." }; + } + const parsed = parseTrendPayload(payload, { windowDays, limit }); + if (!parsed.ok) return { ok: false, status: 502, error: parsed.error }; + return parsed; +} + +/** + * Replace the stored list for this source and window. + * + * Replace rather than append: "what is trending" is a current fact, and a + * month of history would only ever be read as its latest row. Rows the new + * list does not mention are deleted, so a subject that stops trending stops + * steering delivery rather than lingering at its last score forever. + */ +export async function storeTrends( + sb: SupabaseClient, + signals: TrendSignal[], + options: { source?: string; windowDays?: number } = {}, +): Promise<{ stored: number; removed: number }> { + const source = options.source ?? signals[0]?.source ?? TREND_SOURCE; + const windowDays = options.windowDays ?? signals[0]?.windowDays ?? TREND_WINDOW_DAYS; + const ingestedAt = new Date().toISOString(); + + const rows = signals.map((signal) => ({ + source, + topic: signal.topic, + mentions: signal.mentions, + prior_mentions: signal.priorMentions, + score: signal.score, + window_days: windowDays, + generated_at: signal.generatedAt, + ingested_at: ingestedAt, + })); + + if (rows.length) { + const { error } = await sb.from("ad_trend_topics").upsert(rows, { onConflict: "source,window_days,topic" }); + // The unique index is on lower(topic), which PostgREST cannot name as a + // conflict target. Fall back to delete-then-insert for the same effect. + if (error) { + await sb.from("ad_trend_topics").delete().eq("source", source).eq("window_days", windowDays); + const { error: insertError } = await sb.from("ad_trend_topics").insert(rows); + if (insertError) throw new Error(insertError.message); + return { stored: rows.length, removed: 0 }; + } + } + + // Anything not in this pull is no longer trending. + const keep = rows.map((row) => row.topic); + let removed = 0; + if (keep.length) { + const { data } = await sb + .from("ad_trend_topics") + .delete() + .eq("source", source) + .eq("window_days", windowDays) + .not("topic", "in", `(${keep.map((topic) => `"${topic.replace(/"/g, "")}"`).join(",")})`) + .select("id"); + removed = (data ?? []).length; + } + return { stored: rows.length, removed }; +} + +/** Pull and store in one call. What the cron route and the CLI both run. */ +export async function ingestTrends( + sb: SupabaseClient, + config: TrendSourceConfig, + fetchImpl: typeof fetch = fetch, +): Promise { + const fetched = await fetchTrends(config, fetchImpl); + if (!fetched.ok) return fetched; + const windowDays = Math.round(clampNumber(config.windowDays, 1, 90, TREND_WINDOW_DAYS)); + try { + const { stored, removed } = await storeTrends(sb, fetched.signals, { windowDays }); + return { + ok: true, + source: fetched.signals[0]?.source ?? TREND_SOURCE, + windowDays, + stored, + removed, + generatedAt: fetched.generatedAt, + }; + } catch (error) { + return { ok: false, status: 500, error: error instanceof Error ? error.message : "The trend list could not be stored." }; + } +} + +type TrendRow = { + source: string; + topic: string; + mentions: number | null; + prior_mentions: number | null; + score: number | null; + window_days: number | null; + generated_at: string | null; + ingested_at: string | null; +}; + +const rowToSignal = (row: TrendRow): TrendSignal => ({ + source: row.source, + topic: row.topic, + score: Number(row.score) || 0, + mentions: Number(row.mentions) || 0, + priorMentions: Number(row.prior_mentions) || 0, + windowDays: Number(row.window_days) || TREND_WINDOW_DAYS, + generatedAt: row.generated_at, + ingestedAt: row.ingested_at, +}); + +/** + * What is trending right now, for serving and for reporting. + * + * Stale rows are dropped rather than returned: past TREND_MAX_AGE_HOURS the + * list describes a different week, and steering delivery on it is worse than + * not steering it at all. A caller that wants to show the stale list anyway + * (the CLI does, to explain why nothing is being boosted) passes + * `includeStale`. + */ +export async function currentTrends( + sb: SupabaseClient, + options: { source?: string; windowDays?: number; limit?: number; includeStale?: boolean; now?: number } = {}, +): Promise { + const source = options.source ?? TREND_SOURCE; + const windowDays = options.windowDays ?? TREND_WINDOW_DAYS; + const limit = Math.round(clampNumber(options.limit, 1, 200, 50)); + try { + const { data, error } = await sb + .from("ad_trend_topics") + .select("source, topic, mentions, prior_mentions, score, window_days, generated_at, ingested_at") + .eq("source", source) + .eq("window_days", windowDays) + .order("score", { ascending: false }) + .limit(limit); + // Missing table (migration applied by hand, deploy may lead it) reads as + // "nothing is trending", which is the behaviour that existed before. + if (error || !data) return []; + const signals = (data as TrendRow[]).map(rowToSignal); + if (options.includeStale) return signals; + return signals.filter((signal) => !trendsAreStale(signal.ingestedAt, options.now ?? Date.now())); + } catch { + return []; + } +} diff --git a/lib/env.ts b/lib/env.ts index 14f2ee2b..df05e89d 100644 --- a/lib/env.ts +++ b/lib/env.ts @@ -55,6 +55,13 @@ export const env = { // Tor SOCKS5 proxy for reaching .onion targets, e.g. socks5h://127.0.0.1:9050. // Empty = .onion audits/ads are unreachable (fail with a clear message). torSocksUrl: process.env.TOR_SOCKS_URL ?? "", + // SameBrain — chovy.com's read on what founders are asking to build this + // week, which the ad network targets on. The secret is the same value the + // source has; both live in the vault (logicsrc team `crawlproof-com--prod`) + // and are set on the Railway service, never in a committed .env file. + // Unset means no trend ingestion, which degrades to no trend targeting. + samebrainUrl: process.env.SAMEBRAIN_URL ?? "https://chovy.com", + samebrainSecret: process.env.SAMEBRAIN_SECRET ?? "", workerUrl: process.env.WORKER_URL ?? "", workerSecret: process.env.WORKER_SHARED_SECRET ?? "", cronSecret: process.env.CRON_SECRET ?? "", diff --git a/supabase/migrations/20260911120000_ad_trend_targeting.sql b/supabase/migrations/20260911120000_ad_trend_targeting.sql new file mode 100644 index 00000000..d629502f --- /dev/null +++ b/supabase/migrations/20260911120000_ad_trend_targeting.sql @@ -0,0 +1,122 @@ +-- Trending-topic targeting, and the 90-day promo that comes with it. +-- +-- Apply ONE FILE AT A TIME via the Supabase MCP against ywcizjsgrcmhgyplldac +-- (prod's migration history has diverged from this directory, so `db push` +-- would replay files prod already has). Nothing here backfills a row that +-- serving reads, so applying it before or after the deploy is both safe: the +-- code treats a missing column as "no campaign is trending-targeted". +-- +-- Three things: +-- +-- 1. ad_trend_topics — what another service says is trending right now. +-- Written by the ingestion job, read at serve time. One row per +-- (source, topic, window), replaced on each pull rather than appended, +-- because "what is trending" is a current fact and a month of history +-- would only ever be read as the latest row anyway. +-- +-- 2. ad_campaigns.trending_topics / topics — the advertiser's opt-in and the +-- subjects their campaign is about. Serving prefers a campaign whose +-- subjects are trending AND match the page it is filling. +-- +-- 3. ad_promos — the entitlement. An advertiser who turns trending targeting +-- on gets 90 days during which their clicks are metered exactly as usual +-- and billed at nothing. + +-- ---------------------------------------------------------------- signals + +create table if not exists public.ad_trend_topics ( + id uuid primary key default gen_random_uuid(), + -- Where the signal came from. 'samebrain' is chovy.com's read on what + -- founders are asking to build this week. + source text not null default 'samebrain', + topic text not null, + -- How many distinct parties used the term in the window, and in the window + -- before it. Kept alongside the score so a reader can tell a small rising + -- subject from a large flat one without trusting our arithmetic. + mentions integer not null default 0 check (mentions >= 0), + prior_mentions integer not null default 0 check (prior_mentions >= 0), + score numeric not null default 0 check (score >= 0), + window_days integer not null default 7 check (window_days between 1 and 90), + -- When the source generated this answer, and when we stored it. They differ + -- by however long the pull was late, which is exactly the number that says + -- whether a trend list is still worth targeting on. + generated_at timestamptz, + ingested_at timestamptz not null default now() +); + +-- One row per topic per source per window: an ingest updates in place. +create unique index if not exists ad_trend_topics_key + on public.ad_trend_topics(source, window_days, lower(topic)); +create index if not exists ad_trend_topics_fresh + on public.ad_trend_topics(source, window_days, score desc, ingested_at desc); + +comment on table public.ad_trend_topics is + 'Current trending subjects from an external source (samebrain = chovy.com). Replaced on each ingest; not a history table.'; + +alter table public.ad_trend_topics enable row level security; + +-- Readable by any signed-in account: these are subjects, not anybody's data, +-- and an advertiser choosing targeting needs to see what is trending. Writes +-- are the ingestion job's alone, which runs under the service role and is not +-- subject to RLS. +drop policy if exists "trend topics readable" on public.ad_trend_topics; +create policy "trend topics readable" + on public.ad_trend_topics for select + to authenticated + using (true); + +-- ------------------------------------------------------------- targeting + +alter table public.ad_campaigns + add column if not exists trending_topics boolean not null default false, + add column if not exists topics text[] not null default '{}'; + +comment on column public.ad_campaigns.trending_topics is + 'Advertiser opted into trending-topic targeting: this campaign is preferred on pages whose subject is currently trending. Also what the 90-day premium promo is granted against.'; +comment on column public.ad_campaigns.topics is + 'The subjects this campaign is about, normalised. Derived from the destination page when the campaign is created; editable by the owner.'; + +create index if not exists ad_campaigns_trending_idx + on public.ad_campaigns(trending_topics) + where trending_topics; + +-- ----------------------------------------------------------------- promo + +create table if not exists public.ad_promos ( + id uuid primary key default gen_random_uuid(), + owner_id uuid not null references auth.users(id) on delete cascade, + -- Null means the whole account. Today every grant names a campaign, because + -- the entitlement is earned by turning trending targeting on for one. + campaign_id uuid references public.ad_campaigns(id) on delete cascade, + kind text not null default 'trending_premium_90' + check (kind in ('trending_premium_90')), + -- The rate this promo is a discount from, recorded rather than looked up: + -- a promo that outlives a price change must still say what it was worth. + -- 2 cents is the trending-premium CPC (lib/ads/pricing.ts). + cpc_cents integer not null default 2 check (cpc_cents >= 0), + starts_at timestamptz not null default now(), + ends_at timestamptz not null, + -- Set when somebody ends it early; a row is never deleted, because the + -- billing question "was this click free?" has to stay answerable. + revoked_at timestamptz, + note text not null default '', + created_at timestamptz not null default now() +); + +-- One live promo per campaign. A second opt-in re-reads the first rather than +-- extending it: ninety days free is ninety days, not ninety per toggle. +create unique index if not exists ad_promos_campaign_kind + on public.ad_promos(campaign_id, kind) + where campaign_id is not null and revoked_at is null; +create index if not exists ad_promos_owner_idx on public.ad_promos(owner_id, ends_at desc); + +comment on table public.ad_promos is + 'Billing entitlements. While one is live the campaign serves and meters exactly as usual and every click is charged zero — see resolveClick in lib/ads/serve.ts.'; + +alter table public.ad_promos enable row level security; + +drop policy if exists "promos owner read" on public.ad_promos; +create policy "promos owner read" + on public.ad_promos for select + to authenticated + using (auth.uid() = owner_id); diff --git a/tests/ads-trending.test.ts b/tests/ads-trending.test.ts new file mode 100644 index 00000000..d176e74f --- /dev/null +++ b/tests/ads-trending.test.ts @@ -0,0 +1,289 @@ +import { describe, expect, it } from "vitest"; +import { + PROMO_DAYS, + TREND_MATCH_MULTIPLIER, + TREND_MAX_AGE_HOURS, + cleanTopics, + clickChargeCents, + competesForPaid, + fillTier, + matchTrend, + normalizeTopic, + pageTopics, + promoState, + promoWindow, + topicsIntersect, + trendWeight, + trendsAreStale, + type TrendSignal, +} from "@/lib/ads/trending"; +import { parseTrendPayload } from "@/lib/ads/trends"; +import { parseCampaignPatch, parseCampaignRequest } from "@/lib/ads/campaign-request"; +import { TRENDING_CPC_CENTS, CREDIT_CENTS, trendingCpcCredits } from "@/lib/ads/pricing"; +import { campaignBodyFromArgs, promoLine, parseArgs } from "@/cli/index"; + +const DAY = 24 * 60 * 60 * 1000; + +const signal = (topic: string, score = 10): TrendSignal => ({ + source: "samebrain", + topic, + score, + mentions: 5, + priorMentions: 1, + windowDays: 7, + generatedAt: null, + ingestedAt: new Date().toISOString(), +}); + +describe("topics", () => { + it("normalises to the same subject however it was typed", () => { + expect(normalizeTopic("Dog Walking!")).toBe("dog walking"); + expect(normalizeTopic("dog-walking")).toBe("dog walking"); + expect(normalizeTopic("Recipes")).toBe("recipe"); + expect(normalizeTopic(" ")).toBe(""); + }); + + it("matches on whole words, never on a substring", () => { + // The classic version of this bug: "art" matching "smart". + expect(topicsIntersect(["art"], ["smart home"])).toEqual([]); + expect(topicsIntersect(["dog walking"], ["dog walking roster"])).toEqual(["dog walking"]); + expect(topicsIntersect(["payment"], ["payment link"])).toEqual(["payment"]); + expect(topicsIntersect(["crypto payroll"], ["recipe", "meal planner"])).toEqual([]); + }); + + it("reads a page's subject from what CrawlProof already knows about the site", () => { + const topics = pageTopics({ + masterKeywords: ["dog walking", "pet sitting"], + niche: "Local pet services", + slotNiche: null, + projectName: "Paws & Co", + }); + expect(topics).toContain("dog walking"); + expect(topics).toContain("pet sitting"); + // An unknown page must end up with SOMETHING or nothing — never a value + // that matches everything. + expect(pageTopics({})).toEqual([]); + }); + + it("caps and deduplicates what a campaign may claim", () => { + expect(cleanTopics(["Dogs", "dog", " dogs "])).toEqual(["dog"]); + expect(cleanTopics("dog walking, pet sitting")).toEqual(["dog walking", "pet sitting"]); + expect(cleanTopics(Array.from({ length: 40 }, (_, i) => `topic${i}`))).toHaveLength(12); + expect(cleanTopics(null)).toEqual([]); + }); +}); + +describe("matching a campaign to a page", () => { + const trends = [signal("dog walking", 12), signal("crypto payroll", 30)]; + + it("needs BOTH halves: trending, and about this page", () => { + const onTopic = matchTrend(["dog walking"], ["dog walking roster", "pet sitting"], trends); + expect(onTopic).toMatchObject({ matched: true, topics: ["dog walking"] }); + expect(onTopic.score).toBe(12); + + // Trending, but this page is about something else entirely. This is the + // failure everybody complains about: the crypto ad on the recipe blog. + expect(matchTrend(["crypto payroll"], ["sourdough", "bread"], trends).matched).toBe(false); + + // About this page, but nobody is asking about it — ordinary contextual + // targeting, which this feature is not claiming to be. + expect(matchTrend(["sourdough"], ["sourdough starter"], trends).matched).toBe(false); + }); + + it("matches nothing when any of the three inputs is empty", () => { + expect(matchTrend([], ["dog walking"], trends).matched).toBe(false); + expect(matchTrend(["dog walking"], [], trends).matched).toBe(false); + expect(matchTrend(["dog walking"], ["dog walking"], []).matched).toBe(false); + }); + + it("lifts a match in the auction without silencing anything else", () => { + const matched = matchTrend(["dog walking"], ["dog walking"], trends); + const unmatched = matchTrend(["sourdough"], ["dog walking"], trends); + expect(trendWeight(4, matched)).toBe(4 * TREND_MATCH_MULTIPLIER); + expect(trendWeight(4, unmatched)).toBe(4); + // Everything still carries a positive weight, so every eligible campaign + // can still win a fill — "all ads rotate" is the auction's hard rule. + expect(trendWeight(0, unmatched)).toBeGreaterThan(0); + expect(trendWeight(null, matched)).toBeGreaterThan(0); + }); + + it("stops steering delivery once the list is describing a different week", () => { + const now = Date.now(); + expect(trendsAreStale(new Date(now - 60 * 60 * 1000).toISOString(), now)).toBe(false); + expect(trendsAreStale(new Date(now - (TREND_MAX_AGE_HOURS + 1) * 60 * 60 * 1000).toISOString(), now)).toBe(true); + expect(trendsAreStale(null, now)).toBe(true); + expect(trendsAreStale("not a date", now)).toBe(true); + }); +}); + +describe("the trend payload another service sends", () => { + it("keeps what it can use and drops the rest", () => { + const parsed = parseTrendPayload({ + source: "samebrain", + window_days: 7, + generated_at: 1_757_500_000_000, + topics: [ + { topic: "Dog Walking", score: 12.5, count: 6, prior_count: 1 }, + { topic: "dog walking", score: 3 }, + { topic: " ", score: 99 }, + { topic: "crypto payroll", score: "not a number" }, + ], + }); + expect(parsed.ok).toBe(true); + if (!parsed.ok) return; + expect(parsed.signals.map((s) => s.topic)).toEqual(["dog walking", "crypto payroll"]); + expect(parsed.signals[0]).toMatchObject({ score: 12.5, mentions: 6, priorMentions: 1 }); + expect(parsed.signals[1].score).toBe(0); + expect(parsed.generatedAt).toBe(new Date(1_757_500_000_000).toISOString()); + }); + + it("refuses an answer that is not a trend list", () => { + expect(parseTrendPayload({})).toMatchObject({ ok: false }); + expect(parseTrendPayload({ topics: "dog walking" })).toMatchObject({ ok: false }); + }); +}); + +describe("the 90-day promo window", () => { + it("is ninety days from when it was granted", () => { + const start = Date.parse("2026-09-11T00:00:00.000Z"); + const window = promoWindow(start); + expect(window.startsAt).toBe("2026-09-11T00:00:00.000Z"); + expect(Date.parse(window.endsAt) - start).toBe(PROMO_DAYS * DAY); + }); + + it("counts down, and the last partial day still reads as a day", () => { + const start = Date.parse("2026-09-11T00:00:00.000Z"); + const promo = { ...promoWindow(start), cpcCents: TRENDING_CPC_CENTS }; + + expect(promoState(promo, start)).toMatchObject({ active: true, daysRemaining: PROMO_DAYS }); + expect(promoState(promo, start + 30 * DAY).daysRemaining).toBe(PROMO_DAYS - 30); + // Two hours left is still a day left to somebody whose ads are free. + expect(promoState(promo, start + PROMO_DAYS * DAY - 2 * 60 * 60 * 1000)).toMatchObject({ + active: true, + daysRemaining: 1, + }); + // The moment it ends, and after. + expect(promoState(promo, start + PROMO_DAYS * DAY)).toMatchObject({ active: false, daysRemaining: 0 }); + expect(promoState(promo, start + 365 * DAY).active).toBe(false); + // Before it starts, it is not running either. + expect(promoState(promo, start - DAY).active).toBe(false); + }); + + it("is over the moment it is revoked, whatever the end date says", () => { + const start = Date.parse("2026-09-11T00:00:00.000Z"); + const promo = { ...promoWindow(start), revokedAt: new Date(start + 10 * DAY).toISOString() }; + expect(promoState(promo, start + 5 * DAY).active).toBe(true); + expect(promoState(promo, start + 11 * DAY).active).toBe(false); + }); + + it("is nothing at all when there is no promo, or the dates are nonsense", () => { + expect(promoState(null)).toMatchObject({ active: false, daysRemaining: 0, endsAt: null }); + expect(promoState({ startsAt: "never", endsAt: "never" }).active).toBe(false); + }); +}); + +describe("billing at zero", () => { + it("charges nothing while the promo runs, and the ordinary rate after", () => { + const start = Date.now(); + const promo = promoState({ ...promoWindow(start), cpcCents: TRENDING_CPC_CENTS }, start + DAY); + const ended = promoState({ ...promoWindow(start - 200 * DAY), cpcCents: TRENDING_CPC_CENTS }, start); + + expect(clickChargeCents({ promo, cpcCents: 20 })).toBe(0); + expect(clickChargeCents({ promo: ended, cpcCents: 20 })).toBe(20); + expect(clickChargeCents({ promo: promoState(null), cpcCents: 20 })).toBe(20); + }); + + it("quotes the trending CPC as two cents, and says what that is in credits", () => { + expect(TRENDING_CPC_CENTS).toBe(2); + // Below one credit (5c), which is why the promo rate is stated in cents + // and why a post-promo trending campaign still bills at its own bid: the + // credit path moves whole credits only. + expect(trendingCpcCredits()).toBeLessThan(1); + expect(trendingCpcCredits()).toBe(TRENDING_CPC_CENTS / CREDIT_CENTS); + }); + + it("lets a promo campaign compete for the placement it was promised", () => { + // Money and delivery are different questions. A promo campaign books no + // money, but "premium ads running" means it competes in the real auction — + // parked in the backfill pool it would never serve at all on a network + // that has a paying advertiser. + expect(competesForPaid({ selfDeal: false, promoActive: true, hasBudget: false, hasFunds: false })).toBe(true); + // Without a promo, funds and budget still decide. + expect(competesForPaid({ selfDeal: false, promoActive: false, hasBudget: true, hasFunds: true })).toBe(true); + expect(competesForPaid({ selfDeal: false, promoActive: false, hasBudget: false, hasFunds: true })).toBe(false); + // And self-deal never competes for inventory a payer wants, promo or not. + expect(competesForPaid({ selfDeal: true, promoActive: true, hasBudget: true, hasFunds: true })).toBe(false); + }); + + it("never books a promo or a self-deal fill as paid", () => { + // Paid is the only tier that can move money. + expect(fillTier({ selfDeal: false, promoActive: false, hasBudget: true, hasFunds: true })).toBe("paid"); + + // A promo click is charged nothing, so the publisher cannot be paid out of + // it — and on this network, where one account owns both sides of nearly + // every fill, a paid booking would read as revenue on the ROI dashboard. + expect(fillTier({ selfDeal: false, promoActive: true, hasBudget: true, hasFunds: true })).toBe("free"); + // Self-deal stays free whether or not a promo is running. + expect(fillTier({ selfDeal: true, promoActive: true, hasBudget: true, hasFunds: true })).toBe("free"); + expect(fillTier({ selfDeal: true, promoActive: false, hasBudget: true, hasFunds: true })).toBe("free"); + // The existing rules are unchanged. + expect(fillTier({ selfDeal: false, promoActive: false, hasBudget: false, hasFunds: true })).toBe("free"); + expect(fillTier({ selfDeal: false, promoActive: false, hasBudget: true, hasFunds: false })).toBe("free"); + }); +}); + +describe("asking for trending targeting", () => { + it("is accepted from the API in either spelling, and clamped", () => { + const parsed = parseCampaignRequest({ + url: "https://nichedb.dev", + trending_topics: true, + topics: ["Dog Walking", "dogs"], + }); + expect(parsed.ok).toBe(true); + if (!parsed.ok) return; + expect(parsed.request.trendingTopics).toBe(true); + expect(parsed.request.topics).toEqual(["dog walking", "dog"]); + + expect(parseCampaignRequest({ url: "https://nichedb.dev", trending: "true" })).toMatchObject({ + ok: true, + request: { trendingTopics: true }, + }); + // Silence is not a no: an untouched field must stay untouched. + const quiet = parseCampaignRequest({ url: "https://nichedb.dev" }); + expect(quiet.ok && quiet.request.trendingTopics).toBeUndefined(); + }); + + it("can be turned off again through a patch", () => { + expect(parseCampaignPatch({ trending_topics: false })).toMatchObject({ + ok: true, + patch: { trendingTopics: false }, + }); + expect(parseCampaignPatch({ topics: "meal planner" })).toMatchObject({ + ok: true, + patch: { topics: ["meal planner"] }, + }); + expect(parseCampaignPatch({})).toMatchObject({ ok: false }); + }); + + it("travels from the CLI flag into the request body", () => { + const args = parseArgs(["ads", "create", "https://nichedb.dev", "--trending", "--topics=dog walking,pets"]); + expect(campaignBodyFromArgs(args)).toMatchObject({ + url: "https://nichedb.dev", + trending_topics: true, + topics: ["dog walking", "pets"], + }); + // Without the flag the body says nothing about trending at all, so an + // ordinary create cannot turn it on by accident. + expect(campaignBodyFromArgs(parseArgs(["ads", "create", "https://nichedb.dev"]))).not.toHaveProperty("trending_topics"); + }); + + it("tells the advertiser where the promo stands, in one line", () => { + const start = Date.now(); + const active = promoState({ ...promoWindow(start), cpcCents: TRENDING_CPC_CENTS }, start + DAY); + expect(promoLine(active)).toContain("89 days left"); + expect(promoLine(active)).toContain("$0.00"); + expect(promoLine(active)).toContain("$0.02"); + expect(promoLine(promoState({ ...promoWindow(start - 200 * DAY) }, start))).toContain("bill normally"); + expect(promoLine(null)).toBe(""); + }); +}); diff --git a/tests/contract/ads-trend-serving.test.ts b/tests/contract/ads-trend-serving.test.ts new file mode 100644 index 00000000..4445a896 --- /dev/null +++ b/tests/contract/ads-trend-serving.test.ts @@ -0,0 +1,262 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// Serving with trending-topic targeting on. +// +// Two things have to be true at once and neither is provable from the pure +// matcher alone: a campaign whose subject is trending AND is what this page is +// about wins noticeably more fills than one that is neither, and a campaign +// inside its 90-day promo is metered exactly like any other ad while booking +// under a tier that can never move money. +// +// The mock is the same shape as tests/contract/ads-self-deal.test.ts: a proxy +// that answers every PostgREST builder call and resolves to whatever the table +// was set up to return. + +const H = vi.hoisted(() => { + const ADVERTISER = "11111111-1111-1111-1111-111111111111"; + const PUBLISHER = "22222222-2222-2222-2222-222222222222"; + return { + ADVERTISER, + PUBLISHER, + state: { + /** campaign id → the subjects it claims, for the opted-in campaigns. */ + optIns: {} as Record, + trends: [] as { topic: string; score: number }[], + masterKeywords: ["dog walking"] as string[], + promos: [] as Record[], + inserted: [] as Record[], + trendTableMissing: false, + }, + }; +}); +const { ADVERTISER, PUBLISHER, state } = H; + +function chain(result: unknown): unknown { + const c: unknown = new Proxy( + {}, + { + get(_t, prop) { + if (prop === "then") { + return (res: (v: unknown) => unknown, rej: (e: unknown) => unknown) => + Promise.resolve(result).then(res, rej); + } + return () => c; + }, + }, + ); + return c; +} + +function creativeFor(id: string, headline: string) { + return { + id: `cre-${id}`, + campaign_id: id, + format: "terminal_ascii", + headline, + body: "Real advertiser copy.", + cta_text: "Go", + image_url: null, + logo_url: null, + bg_color: "#0b0d10", + fg_color: "#e7e9ee", + accent_color: "#6ee7b7", + font_family: "system-ui", + ad_campaigns: { + id, + owner_id: ADVERTISER, + status: "active", + ref_slug: id, + destination_url: "https://advertiser.example/", + daily_budget_cents: 5000, + spend_today_cents: 0, + spend_date: null, + bid_credits: 4, + }, + }; +} + +vi.mock("@/lib/supabase/service", () => ({ + serviceClient: () => ({ + from(table: string) { + if (table === "ad_slots") { + return chain({ + data: { + id: "slot-1", + status: "active", + formats: ["terminal_ascii"], + // A different account owns the slot, so nothing here is a + // self-deal and a paid booking is genuinely possible. + owner_id: PUBLISHER, + project_id: "project-1", + niche: null, + }, + error: null, + }); + } + if (table === "ad_creatives") { + return chain({ + data: [creativeFor("camp-trending", "Trending advertiser"), creativeFor("camp-plain", "Plain advertiser")], + error: null, + }); + } + if (table === "profiles") { + return chain({ data: [{ id: ADVERTISER, credits_balance: 9999, ad_bonus_credits: 0 }], error: null }); + } + if (table === "ad_campaigns") { + // The opt-in read: campaigns with trending_topics = true. + return chain({ + data: Object.entries(state.optIns).map(([id, topics]) => ({ id, topics })), + error: null, + }); + } + if (table === "ad_trend_topics") { + if (state.trendTableMissing) { + return chain({ data: null, error: { message: 'relation "ad_trend_topics" does not exist' } }); + } + return chain({ + data: state.trends.map((t) => ({ + source: "samebrain", + topic: t.topic, + mentions: 6, + prior_mentions: 1, + score: t.score, + window_days: 7, + generated_at: new Date().toISOString(), + ingested_at: new Date().toISOString(), + })), + error: null, + }); + } + if (table === "lx_site") { + return chain({ data: { master_keywords: state.masterKeywords, niche: "Local pet services" }, error: null }); + } + if (table === "projects") { + return chain({ data: { name: "Paws and Co" }, error: null }); + } + if (table === "ad_promos") { + return chain({ data: state.promos, error: null }); + } + if (table === "ad_impressions") { + return { + insert(payload: Record) { + state.inserted.push(payload); + return chain({ data: { id: "imp-1", ...payload }, error: null }); + }, + }; + } + return chain({ data: null, error: null }); + }, + }), +})); + +async function fills(n: number) { + const { serveAd } = await import("@/lib/ads/serve"); + const out = []; + for (let i = 0; i < n; i++) out.push(await serveAd("slot-1", "terminal_ascii", { device: "terminal" })); + return out; +} + +const DAY = 24 * 60 * 60 * 1000; + +describe("trending-topic targeting decides who fills the page", () => { + beforeEach(() => { + vi.resetModules(); + state.optIns = { "camp-trending": ["dog walking"] }; + state.trends = [{ topic: "dog walking", score: 12 }]; + state.masterKeywords = ["dog walking"]; + state.promos = []; + state.inserted = []; + state.trendTableMissing = false; + }); + + it("prefers the campaign whose trending subject is what this page is about", async () => { + const served = await fills(200); + const real = served.filter((f) => f && f.campaignId !== "house"); + const trending = real.filter((f) => f!.campaignId === "camp-trending").length; + const plain = real.filter((f) => f!.campaignId === "camp-plain").length; + // A preference, not a rule: both bid 4 credits and the matched one carries + // four times the weight, so it should take roughly 80% of fills. + expect(trending).toBeGreaterThan(plain); + // …and the other campaign must still rotate. "All ads rotate" is the + // auction's hard requirement; a targeting feature that starved everything + // else would break it. + expect(plain).toBeGreaterThan(0); + }); + + it("says on the fill which subjects it was chosen for", async () => { + const served = await fills(60); + const match = served.find((f) => f && f.campaignId === "camp-trending"); + expect(match?.trendTopics).toEqual(["dog walking"]); + const other = served.find((f) => f && f.campaignId === "camp-plain"); + expect(other?.trendTopics).toEqual([]); + }); + + it("prefers nobody when the page is about something else", async () => { + state.masterKeywords = ["sourdough", "bread"]; + const served = await fills(200); + const real = served.filter((f) => f && f.campaignId !== "house"); + const trending = real.filter((f) => f!.campaignId === "camp-trending").length; + const plain = real.filter((f) => f!.campaignId === "camp-plain").length; + // Even delivery, within the noise of a 200-fill lottery. The trending ad + // must not follow its subject onto a page that has nothing to do with it. + expect(Math.abs(trending - plain)).toBeLessThan(real.length * 0.35); + expect(real.every((f) => (f!.trendTopics ?? []).length === 0)).toBe(true); + }); + + it("serves exactly as it did before when the trend table is not there yet", async () => { + // The migration is applied by hand, so a deploy can lead it. A missing + // table must read as "nothing is trending", never as an error. + state.trendTableMissing = true; + const served = await fills(60); + const real = served.filter((f) => f && f.campaignId !== "house"); + expect(real.length).toBeGreaterThan(0); + expect(real.every((f) => (f!.trendTopics ?? []).length === 0)).toBe(true); + }); +}); + +describe("a campaign inside its 90-day promo", () => { + beforeEach(() => { + vi.resetModules(); + state.optIns = { "camp-trending": ["dog walking"] }; + state.trends = [{ topic: "dog walking", score: 12 }]; + state.masterKeywords = ["dog walking"]; + state.inserted = []; + state.trendTableMissing = false; + state.promos = [ + { + id: "promo-1", + owner_id: ADVERTISER, + campaign_id: "camp-trending", + kind: "trending_premium_90", + cpc_cents: 2, + starts_at: new Date(Date.now() - 5 * DAY).toISOString(), + ends_at: new Date(Date.now() + 85 * DAY).toISOString(), + revoked_at: null, + }, + ]; + }); + + it("is metered like any other ad — the impression is still recorded", async () => { + await fills(60); + const promoRows = state.inserted.filter((row) => row.campaign_id === "camp-trending"); + expect(promoRows.length).toBeGreaterThan(0); + }); + + it("never books as paid, so no spend and no publisher earnings are invented", async () => { + await fills(60); + const promoRows = state.inserted.filter((row) => row.campaign_id === "camp-trending"); + expect(new Set(promoRows.map((row) => row.tier))).toEqual(new Set(["free"])); + // The advertiser paying nothing does not make the OTHER advertiser free: + // a funded campaign on somebody else's slot still books as paid. + const plainRows = state.inserted.filter((row) => row.campaign_id === "camp-plain"); + expect(plainRows.length).toBeGreaterThan(0); + expect(new Set(plainRows.map((row) => row.tier))).toEqual(new Set(["paid"])); + }); + + it("still carries the promo flag on the fill", async () => { + const served = await fills(60); + const match = served.find((f) => f && f.campaignId === "camp-trending"); + expect(match?.promo).toBe(true); + expect(served.find((f) => f && f.campaignId === "camp-plain")?.promo).toBe(false); + }); +});