From 17bc01e45cfd79a295d353be05c9c458a3d005b4 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sat, 5 Sep 2026 23:11:00 +0000 Subject: [PATCH] Manage a campaign by API token: read it with its delivery, change it, delete it GET/PATCH/DELETE /api/ads/v1/campaigns/[id], by id or ref slug. GET carries the stats view (impressions, clicks, spend, the free halves) plus the visits the tracker attributed to the campaign on the caller's own sites, by day. PATCH takes name, daily_budget_cents, bid_credits and status; going active needs a ready creative, as in the dashboard. DELETE removes it, metering included. The CLI gains ads show|pause|resume|budget|delete. This is what the myna plugin uses so a post's ad can be managed from the terminal it was posted from. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01YafYxayh7Gqe5MWNNQMev2 --- app/api/ads/v1/campaigns/[id]/route.ts | 66 ++++++++++++++++++ cli/index.ts | 63 ++++++++++++++++- lib/ads/campaign-request.ts | 39 +++++++++++ lib/ads/campaigns.ts | 94 +++++++++++++++++++++++++- tests/ads-api-requests.test.ts | 17 +++++ 5 files changed, 275 insertions(+), 4 deletions(-) create mode 100644 app/api/ads/v1/campaigns/[id]/route.ts diff --git a/app/api/ads/v1/campaigns/[id]/route.ts b/app/api/ads/v1/campaigns/[id]/route.ts new file mode 100644 index 0000000..04cea3a --- /dev/null +++ b/app/api/ads/v1/campaigns/[id]/route.ts @@ -0,0 +1,66 @@ +// /api/ads/v1/campaigns/[id] — one campaign, by id or ref slug (crawlproof-ad-144). +// +// 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? } +// status is active | paused | draft. Going active needs a creative. +// DELETE removes it, metering included. Pause keeps the history. +// +// Same auth as the collection route. This is what `crawlproof ads show|pause| +// resume|budget|delete` and the myna plugin call. + +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 { env } from "@/lib/env"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +const siteUrl = () => (env.siteUrl || "https://crawlproof.com").replace(/\/$/, ""); +type Ctx = { params: Promise<{ id: string }> }; + +async function load(req: NextRequest, ctx: Ctx) { + const auth = await authenticateBearer(req); + if (!auth.ok) return { error: NextResponse.json({ error: auth.error }, { status: auth.status }) }; + const { id } = await ctx.params; + const sb = serviceClient(); + const campaign = await findCampaign(sb, auth.userId, id); + if (!campaign) return { error: NextResponse.json({ error: "No such campaign." }, { status: 404 }) }; + return { sb, userId: auth.userId, campaign }; +} + +const withUrl = (c: T) => ({ ...c, dashboard_url: `${siteUrl()}/dashboard/ads/${c.id}` }); + +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 }); +} + +export async function PATCH(req: NextRequest, ctx: Ctx) { + const loaded = await load(req, ctx); + if ("error" in loaded) return loaded.error; + let body: Record; + try { + body = (await req.json()) as Record; + } catch { + return NextResponse.json({ error: "Body must be JSON." }, { status: 400 }); + } + const parsed = parseCampaignPatch(body ?? {}); + if (!parsed.ok) return NextResponse.json({ error: parsed.error }, { status: 400 }); + const result = await patchCampaign(loaded.sb, loaded.userId, loaded.campaign, parsed.patch); + if (!result.ok) return NextResponse.json({ error: result.error }, { status: result.status }); + return NextResponse.json(withUrl(result.campaign)); +} + +export async function DELETE(req: NextRequest, ctx: Ctx) { + const loaded = await load(req, ctx); + if ("error" in loaded) return loaded.error; + const result = await deleteCampaign(loaded.sb, loaded.userId, loaded.campaign); + if (!result.ok) return NextResponse.json({ error: result.error }, { status: result.status }); + return NextResponse.json({ ok: true, deleted: loaded.campaign.ref_slug }); +} diff --git a/cli/index.ts b/cli/index.ts index 508621f..81ac2d1 100644 --- a/cli/index.ts +++ b/cli/index.ts @@ -197,7 +197,7 @@ function apiBase(args: Args): string { async function apiCall( args: Args, - method: "GET" | "POST", + method: "GET" | "POST" | "PATCH" | "DELETE", path: string, body?: Record, ): Promise<{ status: number; json: Record }> { @@ -265,6 +265,55 @@ async function cmdAds(args: Args): Promise { } return 0; } + if (sub === "show" || sub === "pause" || sub === "resume" || sub === "budget" || sub === "delete") { + const ref = args.positional[1]; + if (!ref) { + console.error(`usage: crawlproof ads ${sub} ${sub === "budget" ? " " : ""}`); + return 2; + } + const path = `/api/ads/v1/campaigns/${encodeURIComponent(ref)}`; + let method: "GET" | "PATCH" | "DELETE" = "GET"; + let body: Record | undefined; + if (sub === "pause") (method = "PATCH"), (body = { status: "paused" }); + if (sub === "resume") (method = "PATCH"), (body = { status: "active" }); + if (sub === "budget") { + const cents = Number(args.positional[2]); + if (!Number.isInteger(cents) || cents < 0) { + console.error("usage: crawlproof ads budget "); + return 2; + } + (method = "PATCH"), (body = { daily_budget_cents: cents }); + } + if (sub === "delete") { + if (!args.flags.yes) { + console.error("delete removes the campaign and its metering; pass --yes. Pause keeps the history."); + return 2; + } + method = "DELETE"; + } + const { status, json } = await apiCall(args, method, path, body); + if (status >= 400) { + console.error(`ads ${sub} failed: ${status} ${json.error ?? ""}`); + return 1; + } + if (args.flags.json) { + process.stdout.write(`${JSON.stringify(json, null, 2)}\n`); + return 0; + } + if (sub === "delete") { + process.stdout.write(`deleted ${json.deleted}\n`); + return 0; + } + const stats = json.stats as Record | undefined; + process.stdout.write(`${json.status} ${json.ref_slug} ${json.name}\n ${json.destination_url}\n ${json.daily_budget_cents}¢/day, bid ${json.bid_credits ?? "default"}\n`); + if (stats) { + const visits = stats.visits as { total: number } | undefined; + process.stdout.write( + ` impressions ${stats.impressions} (+${stats.free_impressions} free) · clicks ${stats.clicks} (+${stats.free_clicks} free) · spent ${stats.spent_cents}¢ · visits attributed ${visits?.total ?? 0}\n`, + ); + } + return 0; + } if (sub === "list" || sub === undefined) { const limit = (args.flags.limit as string | undefined) ?? "20"; const { status, json } = await apiCall(args, "GET", `/api/ads/v1/campaigns?limit=${encodeURIComponent(limit)}`); @@ -283,7 +332,7 @@ async function cmdAds(args: Args): Promise { } return 0; } - console.error(`unknown: crawlproof ads ${sub} (expected: create | list)`); + console.error(`unknown: crawlproof ads ${sub} (expected: create | list | show | pause | resume | budget | delete)`); return 2; } @@ -367,6 +416,16 @@ COMMANDS 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. + + ads pause | ads resume | ads budget + Change a campaign in place. A ref looks like crawlproof-ad-144. + + ads delete --yes + Remove it, metering included. Pause keeps the history. + slots create [--placement=inline] [--format=text_link] [--formats=a,b] [--inactive] [--no-tracking] [--json] A publisher slot on a site you own, named by hostname or URL. The site's project is found or created with the stats tracker on, and diff --git a/lib/ads/campaign-request.ts b/lib/ads/campaign-request.ts index e1363b0..1965f0c 100644 --- a/lib/ads/campaign-request.ts +++ b/lib/ads/campaign-request.ts @@ -55,3 +55,42 @@ export function parseCampaignRequest(body: Record): { ok: true; if (statusRaw === "active" || statusRaw === "draft") request.status = statusRaw; return { ok: true, request, url: check.url }; } + +export type CampaignPatch = { + name?: string; + dailyBudgetCents?: number; + bidCredits?: number; + status?: "active" | "paused" | "draft"; +}; + +/** Pure: a PATCH body, normalised with the dashboard's clamps. Empty is an error. */ +export function parseCampaignPatch(body: Record): { ok: true; patch: CampaignPatch } | { ok: false; error: string } { + const patch: CampaignPatch = {}; + if (body.name !== undefined) { + if (typeof body.name !== "string" || !body.name.trim()) return { ok: false, error: "name must be a non-empty string." }; + patch.name = body.name.trim().slice(0, 120); + } + const budgetRaw = body.daily_budget_cents ?? body.dailyBudgetCents; + if (budgetRaw !== undefined) { + const n = Number(budgetRaw); + if (!Number.isFinite(n) || n < 0) return { ok: false, error: "daily_budget_cents must be a non-negative number." }; + patch.dailyBudgetCents = Math.round(n); + } + const bidRaw = body.bid_credits ?? body.bidCredits; + if (bidRaw !== undefined) { + const n = Number(bidRaw); + if (!Number.isFinite(n) || n < 1) return { ok: false, error: "bid_credits must be at least 1." }; + patch.bidCredits = Math.min(200, Math.round(n)); + } + if (body.status !== undefined) { + if (body.status !== "active" && body.status !== "paused" && body.status !== "draft") { + return { ok: false, error: 'status must be "active", "paused" or "draft".' }; + } + 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." }; + return { ok: true, patch }; +} + +/** A campaign is named by its id or by its ref slug (crawlproof-ad-144). */ +export const isRefSlug = (value: string): boolean => /^crawlproof-ad-\d+$/i.test(value.trim()); diff --git a/lib/ads/campaigns.ts b/lib/ads/campaigns.ts index f14841c..d2c0e22 100644 --- a/lib/ads/campaigns.ts +++ b/lib/ads/campaigns.ts @@ -12,9 +12,9 @@ // announced twice should not be paying for two campaigns. import type { SupabaseClient } from "@supabase/supabase-js"; -import { parseCampaignRequest, domainOf, type CampaignRequest, type CampaignStatus } from "@/lib/ads/campaign-request"; +import { parseCampaignRequest, parseCampaignPatch, isRefSlug, domainOf, type CampaignRequest, type CampaignStatus, type CampaignPatch } from "@/lib/ads/campaign-request"; -export { parseCampaignRequest, domainOf, type CampaignRequest, type CampaignStatus }; +export { parseCampaignRequest, parseCampaignPatch, isRefSlug, domainOf, type CampaignRequest, type CampaignStatus, type CampaignPatch }; 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"; @@ -193,3 +193,93 @@ export async function listCampaigns(input: { sb: SupabaseClient; userId: string; .limit(Math.min(200, Math.max(1, input.limit))); return ((data as CampaignSummary[]) ?? []).map((c) => ({ ...c, dashboard_url: `${input.siteUrl}/dashboard/ads/${c.id}` })); } + +// ------------------------------------------------------------ one campaign + +const CAMPAIGN_COLUMNS = "id, ref_slug, name, status, destination_url, daily_budget_cents, bid_credits, created_at"; + +/** The caller's campaign by id or ref slug, or null. */ +export async function findCampaign(sb: SupabaseClient, userId: string, idOrRef: string): Promise { + const key = idOrRef.trim(); + let query = sb.from("ad_campaigns").select(CAMPAIGN_COLUMNS).eq("owner_id", userId); + query = isRefSlug(key) ? query.eq("ref_slug", key.toLowerCase()) : query.eq("id", key); + const { data } = await query.maybeSingle(); + return (data as CampaignSummary | null) ?? null; +} + +export type CampaignStats = { + impressions: number; + clicks: number; + spent_cents: number; + spend_today_cents: number; + free_impressions: number; + free_clicks: number; + /** Visits the tracker attributed to this campaign on the caller's own sites, by day. */ + visits: { total: number; days: { day: string; visits: number }[] }; +}; + +const n = (v: unknown): number => { + const x = Number(v); + return Number.isFinite(x) ? x : 0; +}; + +/** Delivery from the stats view, plus ad: visits on the caller's tracked sites. */ +export async function campaignStats(sb: SupabaseClient, userId: string, campaign: CampaignSummary): Promise { + const { data: row } = await sb.from("ad_campaign_stats").select("*").eq("campaign_id", campaign.id).maybeSingle(); + const r = (row as Record | null) ?? {}; + + const { data: projects } = await sb.from("projects").select("id").eq("owner_id", userId); + const ids = ((projects as { id: string }[]) ?? []).map((p) => p.id); + const days: { day: string; visits: number }[] = []; + if (ids.length) { + const { data: rows } = await sb + .from("tracker_daily_stats") + .select("day, count") + .in("project_id", ids) + .eq("bucket", `ad:${campaign.ref_slug}`) + .order("day", { ascending: false }) + .limit(60); + const byDay = new Map(); + for (const item of (rows as { day: string; count: number }[]) ?? []) byDay.set(item.day, (byDay.get(item.day) ?? 0) + n(item.count)); + for (const [day, visits] of byDay) days.push({ day, visits }); + } + return { + impressions: n(r.impressions), + clicks: n(r.clicks), + spent_cents: n(r.spent_cents), + spend_today_cents: n(r.spend_today_cents), + free_impressions: n(r.free_impressions), + free_clicks: n(r.free_clicks), + visits: { total: days.reduce((sum, d) => sum + d.visits, 0), days }, + }; +} + +export async function patchCampaign( + sb: SupabaseClient, + userId: string, + campaign: CampaignSummary, + patch: CampaignPatch, +): Promise<{ ok: true; campaign: CampaignSummary } | { ok: false; status: number; error: string }> { + const update: Record = {}; + if (patch.name !== undefined) update.name = patch.name; + if (patch.dailyBudgetCents !== undefined) update.daily_budget_cents = patch.dailyBudgetCents; + if (patch.bidCredits !== undefined) update.bid_credits = patch.bidCredits; + if (patch.status !== undefined) { + if (patch.status === "active") { + // The dashboard's rule: nothing goes live without a creative to show. + const { count } = await sb.from("ad_creatives").select("id", { count: "exact", head: true }).eq("campaign_id", campaign.id).eq("status", "ready"); + if (!count) return { ok: false, status: 409, error: "This campaign has no ready creative; add one in the dashboard before activating." }; + } + 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 (error || !data) return { ok: false, status: 500, error: error?.message ?? "Failed to update the campaign." }; + return { ok: true, campaign: data as CampaignSummary }; +} + +/** Delete outright. Impressions and clicks cascade with it; pausing keeps them. */ +export async function deleteCampaign(sb: SupabaseClient, userId: string, campaign: CampaignSummary): Promise<{ ok: true } | { ok: false; status: number; error: string }> { + const { error } = await sb.from("ad_campaigns").delete().eq("id", campaign.id).eq("owner_id", userId); + if (error) return { ok: false, status: 500, error: error.message }; + return { ok: true }; +} diff --git a/tests/ads-api-requests.test.ts b/tests/ads-api-requests.test.ts index 17dfa58..fc7f12c 100644 --- a/tests/ads-api-requests.test.ts +++ b/tests/ads-api-requests.test.ts @@ -80,3 +80,20 @@ describe("crawlproof ads / slots CLI", () => { expect(slotBodyFromArgs(parseArgs(["slots", "create", "x.dev", "--formats=a,b", "--inactive"]))).toEqual({ site: "x.dev", formats: ["a", "b"], status: "inactive" }); }); }); + +describe("PATCH /api/ads/v1/campaigns/[id] body", () => { + it("normalises a partial update and refuses an empty one", async () => { + const { parseCampaignPatch, isRefSlug } = await import("@/lib/ads/campaign-request"); + expect(parseCampaignPatch({ status: "paused" })).toEqual({ ok: true, patch: { status: "paused" } }); + expect(parseCampaignPatch({ daily_budget_cents: 250.6, bid_credits: 900, name: " New " })).toEqual({ + ok: true, + patch: { dailyBudgetCents: 251, bidCredits: 200, name: "New" }, + }); + expect(parseCampaignPatch({})).toMatchObject({ ok: false, error: expect.stringContaining("Nothing to change") }); + expect(parseCampaignPatch({ status: "exhausted" })).toMatchObject({ ok: false }); + expect(parseCampaignPatch({ name: "" })).toMatchObject({ ok: false }); + expect(parseCampaignPatch({ bid_credits: 0 })).toMatchObject({ ok: false }); + expect(isRefSlug("crawlproof-ad-144")).toBe(true); + expect(isRefSlug("1a2fc904-a5b2-4c5d-a5bd-cda5ff471692")).toBe(false); + }); +});