diff --git a/.env.example b/.env.example index f97bf82..d3c18a1 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 4f91fb0..0bbba21 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 5ccd057..38a7a5b 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 f737486..6670144 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 41753a1..2b44bbd 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 04cea3a..e95e426 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 3ed1340..5ea19c6 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 0000000..dff2bd8 --- /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 0000000..73f1196 --- /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 a98c31b..9ea518c 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 1965f0c..c255589 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 d2c0e22..ce7ef8d 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 35a73fa..62eee32 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 0000000..09df540 --- /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 5eaeddf..1eb21ed 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 0000000..1f53bbd --- /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 0000000..9457dff --- /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 0000000..7e3e9e6 --- /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 14f2ee2..df05e89 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 0000000..d629502 --- /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 0000000..d176e74 --- /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 0000000..4445a89 --- /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); + }); +});