Skip to content

Commit 24db52d

Browse files
ralyodioclaude
andauthored
Manage a campaign by API token: read it with its delivery, change it, delete it (#238)
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. Claude-Session: https://claude.ai/code/session_01YafYxayh7Gqe5MWNNQMev2 Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 39204d7 commit 24db52d

5 files changed

Lines changed: 275 additions & 4 deletions

File tree

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
// /api/ads/v1/campaigns/[id] — one campaign, by id or ref slug (crawlproof-ad-144).
2+
//
3+
// GET the campaign and its delivery: impressions, clicks, spend, and the
4+
// visits the tracker attributed to it (bucket ad:<ref>) on the
5+
// caller's own sites.
6+
// PATCH { name?, daily_budget_cents?, bid_credits?, status? }
7+
// status is active | paused | draft. Going active needs a creative.
8+
// DELETE removes it, metering included. Pause keeps the history.
9+
//
10+
// Same auth as the collection route. This is what `crawlproof ads show|pause|
11+
// resume|budget|delete` and the myna plugin call.
12+
13+
import { NextResponse, type NextRequest } from "next/server";
14+
import { serviceClient } from "@/lib/supabase/service";
15+
import { authenticateBearer } from "@/lib/sp/apiAuth";
16+
import { campaignStats, deleteCampaign, findCampaign, parseCampaignPatch, patchCampaign } from "@/lib/ads/campaigns";
17+
import { env } from "@/lib/env";
18+
19+
export const runtime = "nodejs";
20+
export const dynamic = "force-dynamic";
21+
22+
const siteUrl = () => (env.siteUrl || "https://crawlproof.com").replace(/\/$/, "");
23+
type Ctx = { params: Promise<{ id: string }> };
24+
25+
async function load(req: NextRequest, ctx: Ctx) {
26+
const auth = await authenticateBearer(req);
27+
if (!auth.ok) return { error: NextResponse.json({ error: auth.error }, { status: auth.status }) };
28+
const { id } = await ctx.params;
29+
const sb = serviceClient();
30+
const campaign = await findCampaign(sb, auth.userId, id);
31+
if (!campaign) return { error: NextResponse.json({ error: "No such campaign." }, { status: 404 }) };
32+
return { sb, userId: auth.userId, campaign };
33+
}
34+
35+
const withUrl = <T extends { id: string }>(c: T) => ({ ...c, dashboard_url: `${siteUrl()}/dashboard/ads/${c.id}` });
36+
37+
export async function GET(req: NextRequest, ctx: Ctx) {
38+
const loaded = await load(req, ctx);
39+
if ("error" in loaded) return loaded.error;
40+
const stats = await campaignStats(loaded.sb, loaded.userId, loaded.campaign);
41+
return NextResponse.json({ ...withUrl(loaded.campaign), stats });
42+
}
43+
44+
export async function PATCH(req: NextRequest, ctx: Ctx) {
45+
const loaded = await load(req, ctx);
46+
if ("error" in loaded) return loaded.error;
47+
let body: Record<string, unknown>;
48+
try {
49+
body = (await req.json()) as Record<string, unknown>;
50+
} catch {
51+
return NextResponse.json({ error: "Body must be JSON." }, { status: 400 });
52+
}
53+
const parsed = parseCampaignPatch(body ?? {});
54+
if (!parsed.ok) return NextResponse.json({ error: parsed.error }, { status: 400 });
55+
const result = await patchCampaign(loaded.sb, loaded.userId, loaded.campaign, parsed.patch);
56+
if (!result.ok) return NextResponse.json({ error: result.error }, { status: result.status });
57+
return NextResponse.json(withUrl(result.campaign));
58+
}
59+
60+
export async function DELETE(req: NextRequest, ctx: Ctx) {
61+
const loaded = await load(req, ctx);
62+
if ("error" in loaded) return loaded.error;
63+
const result = await deleteCampaign(loaded.sb, loaded.userId, loaded.campaign);
64+
if (!result.ok) return NextResponse.json({ error: result.error }, { status: result.status });
65+
return NextResponse.json({ ok: true, deleted: loaded.campaign.ref_slug });
66+
}

‎cli/index.ts‎

Lines changed: 61 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -197,7 +197,7 @@ function apiBase(args: Args): string {
197197

198198
async function apiCall(
199199
args: Args,
200-
method: "GET" | "POST",
200+
method: "GET" | "POST" | "PATCH" | "DELETE",
201201
path: string,
202202
body?: Record<string, unknown>,
203203
): Promise<{ status: number; json: Record<string, unknown> }> {
@@ -265,6 +265,55 @@ async function cmdAds(args: Args): Promise<number> {
265265
}
266266
return 0;
267267
}
268+
if (sub === "show" || sub === "pause" || sub === "resume" || sub === "budget" || sub === "delete") {
269+
const ref = args.positional[1];
270+
if (!ref) {
271+
console.error(`usage: crawlproof ads ${sub} <ref-or-id>${sub === "budget" ? " <cents>" : ""}`);
272+
return 2;
273+
}
274+
const path = `/api/ads/v1/campaigns/${encodeURIComponent(ref)}`;
275+
let method: "GET" | "PATCH" | "DELETE" = "GET";
276+
let body: Record<string, unknown> | undefined;
277+
if (sub === "pause") (method = "PATCH"), (body = { status: "paused" });
278+
if (sub === "resume") (method = "PATCH"), (body = { status: "active" });
279+
if (sub === "budget") {
280+
const cents = Number(args.positional[2]);
281+
if (!Number.isInteger(cents) || cents < 0) {
282+
console.error("usage: crawlproof ads budget <ref-or-id> <cents per day>");
283+
return 2;
284+
}
285+
(method = "PATCH"), (body = { daily_budget_cents: cents });
286+
}
287+
if (sub === "delete") {
288+
if (!args.flags.yes) {
289+
console.error("delete removes the campaign and its metering; pass --yes. Pause keeps the history.");
290+
return 2;
291+
}
292+
method = "DELETE";
293+
}
294+
const { status, json } = await apiCall(args, method, path, body);
295+
if (status >= 400) {
296+
console.error(`ads ${sub} failed: ${status} ${json.error ?? ""}`);
297+
return 1;
298+
}
299+
if (args.flags.json) {
300+
process.stdout.write(`${JSON.stringify(json, null, 2)}\n`);
301+
return 0;
302+
}
303+
if (sub === "delete") {
304+
process.stdout.write(`deleted ${json.deleted}\n`);
305+
return 0;
306+
}
307+
const stats = json.stats as Record<string, unknown> | undefined;
308+
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`);
309+
if (stats) {
310+
const visits = stats.visits as { total: number } | undefined;
311+
process.stdout.write(
312+
` impressions ${stats.impressions} (+${stats.free_impressions} free) · clicks ${stats.clicks} (+${stats.free_clicks} free) · spent ${stats.spent_cents}¢ · visits attributed ${visits?.total ?? 0}\n`,
313+
);
314+
}
315+
return 0;
316+
}
268317
if (sub === "list" || sub === undefined) {
269318
const limit = (args.flags.limit as string | undefined) ?? "20";
270319
const { status, json } = await apiCall(args, "GET", `/api/ads/v1/campaigns?limit=${encodeURIComponent(limit)}`);
@@ -283,7 +332,7 @@ async function cmdAds(args: Args): Promise<number> {
283332
}
284333
return 0;
285334
}
286-
console.error(`unknown: crawlproof ads ${sub} (expected: create | list)`);
335+
console.error(`unknown: crawlproof ads ${sub} (expected: create | list | show | pause | resume | budget | delete)`);
287336
return 2;
288337
}
289338

@@ -367,6 +416,16 @@ COMMANDS
367416
ads list [--limit=20] [--json]
368417
Your campaigns, newest first.
369418
419+
ads show <ref-or-id> [--json]
420+
One campaign with its delivery: impressions, clicks, spend, and the
421+
visits the tracker attributed to it on your own sites.
422+
423+
ads pause <ref-or-id> | ads resume <ref-or-id> | ads budget <ref-or-id> <cents>
424+
Change a campaign in place. A ref looks like crawlproof-ad-144.
425+
426+
ads delete <ref-or-id> --yes
427+
Remove it, metering included. Pause keeps the history.
428+
370429
slots create <site> [--placement=inline] [--format=text_link] [--formats=a,b] [--inactive] [--no-tracking] [--json]
371430
A publisher slot on a site you own, named by hostname or URL. The
372431
site's project is found or created with the stats tracker on, and

‎lib/ads/campaign-request.ts‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,3 +55,42 @@ export function parseCampaignRequest(body: Record<string, unknown>): { ok: true;
5555
if (statusRaw === "active" || statusRaw === "draft") request.status = statusRaw;
5656
return { ok: true, request, url: check.url };
5757
}
58+
59+
export type CampaignPatch = {
60+
name?: string;
61+
dailyBudgetCents?: number;
62+
bidCredits?: number;
63+
status?: "active" | "paused" | "draft";
64+
};
65+
66+
/** Pure: a PATCH body, normalised with the dashboard's clamps. Empty is an error. */
67+
export function parseCampaignPatch(body: Record<string, unknown>): { ok: true; patch: CampaignPatch } | { ok: false; error: string } {
68+
const patch: CampaignPatch = {};
69+
if (body.name !== undefined) {
70+
if (typeof body.name !== "string" || !body.name.trim()) return { ok: false, error: "name must be a non-empty string." };
71+
patch.name = body.name.trim().slice(0, 120);
72+
}
73+
const budgetRaw = body.daily_budget_cents ?? body.dailyBudgetCents;
74+
if (budgetRaw !== undefined) {
75+
const n = Number(budgetRaw);
76+
if (!Number.isFinite(n) || n < 0) return { ok: false, error: "daily_budget_cents must be a non-negative number." };
77+
patch.dailyBudgetCents = Math.round(n);
78+
}
79+
const bidRaw = body.bid_credits ?? body.bidCredits;
80+
if (bidRaw !== undefined) {
81+
const n = Number(bidRaw);
82+
if (!Number.isFinite(n) || n < 1) return { ok: false, error: "bid_credits must be at least 1." };
83+
patch.bidCredits = Math.min(200, Math.round(n));
84+
}
85+
if (body.status !== undefined) {
86+
if (body.status !== "active" && body.status !== "paused" && body.status !== "draft") {
87+
return { ok: false, error: 'status must be "active", "paused" or "draft".' };
88+
}
89+
patch.status = body.status;
90+
}
91+
if (!Object.keys(patch).length) return { ok: false, error: "Nothing to change: send name, daily_budget_cents, bid_credits or status." };
92+
return { ok: true, patch };
93+
}
94+
95+
/** A campaign is named by its id or by its ref slug (crawlproof-ad-144). */
96+
export const isRefSlug = (value: string): boolean => /^crawlproof-ad-\d+$/i.test(value.trim());

‎lib/ads/campaigns.ts‎

Lines changed: 92 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,9 @@
1212
// announced twice should not be paying for two campaigns.
1313

1414
import type { SupabaseClient } from "@supabase/supabase-js";
15-
import { parseCampaignRequest, domainOf, type CampaignRequest, type CampaignStatus } from "@/lib/ads/campaign-request";
15+
import { parseCampaignRequest, parseCampaignPatch, isRefSlug, domainOf, type CampaignRequest, type CampaignStatus, type CampaignPatch } from "@/lib/ads/campaign-request";
1616

17-
export { parseCampaignRequest, domainOf, type CampaignRequest, type CampaignStatus };
17+
export { parseCampaignRequest, parseCampaignPatch, isRefSlug, domainOf, type CampaignRequest, type CampaignStatus, type CampaignPatch };
1818
import { getOrCreateDefaultOrg } from "@/lib/orgs";
1919
import { generateAdCreatives, cleanSummary, creativesFromCopy, templateCopy, summaryDomain, type AdCreative, type AdSummary } from "@/lib/ads/creative";
2020
import { extractSiteBrand, type SiteBrand } from "@/lib/ads/brand";
@@ -193,3 +193,93 @@ export async function listCampaigns(input: { sb: SupabaseClient; userId: string;
193193
.limit(Math.min(200, Math.max(1, input.limit)));
194194
return ((data as CampaignSummary[]) ?? []).map((c) => ({ ...c, dashboard_url: `${input.siteUrl}/dashboard/ads/${c.id}` }));
195195
}
196+
197+
// ------------------------------------------------------------ one campaign
198+
199+
const CAMPAIGN_COLUMNS = "id, ref_slug, name, status, destination_url, daily_budget_cents, bid_credits, created_at";
200+
201+
/** The caller's campaign by id or ref slug, or null. */
202+
export async function findCampaign(sb: SupabaseClient, userId: string, idOrRef: string): Promise<CampaignSummary | null> {
203+
const key = idOrRef.trim();
204+
let query = sb.from("ad_campaigns").select(CAMPAIGN_COLUMNS).eq("owner_id", userId);
205+
query = isRefSlug(key) ? query.eq("ref_slug", key.toLowerCase()) : query.eq("id", key);
206+
const { data } = await query.maybeSingle();
207+
return (data as CampaignSummary | null) ?? null;
208+
}
209+
210+
export type CampaignStats = {
211+
impressions: number;
212+
clicks: number;
213+
spent_cents: number;
214+
spend_today_cents: number;
215+
free_impressions: number;
216+
free_clicks: number;
217+
/** Visits the tracker attributed to this campaign on the caller's own sites, by day. */
218+
visits: { total: number; days: { day: string; visits: number }[] };
219+
};
220+
221+
const n = (v: unknown): number => {
222+
const x = Number(v);
223+
return Number.isFinite(x) ? x : 0;
224+
};
225+
226+
/** Delivery from the stats view, plus ad:<ref> visits on the caller's tracked sites. */
227+
export async function campaignStats(sb: SupabaseClient, userId: string, campaign: CampaignSummary): Promise<CampaignStats> {
228+
const { data: row } = await sb.from("ad_campaign_stats").select("*").eq("campaign_id", campaign.id).maybeSingle();
229+
const r = (row as Record<string, unknown> | null) ?? {};
230+
231+
const { data: projects } = await sb.from("projects").select("id").eq("owner_id", userId);
232+
const ids = ((projects as { id: string }[]) ?? []).map((p) => p.id);
233+
const days: { day: string; visits: number }[] = [];
234+
if (ids.length) {
235+
const { data: rows } = await sb
236+
.from("tracker_daily_stats")
237+
.select("day, count")
238+
.in("project_id", ids)
239+
.eq("bucket", `ad:${campaign.ref_slug}`)
240+
.order("day", { ascending: false })
241+
.limit(60);
242+
const byDay = new Map<string, number>();
243+
for (const item of (rows as { day: string; count: number }[]) ?? []) byDay.set(item.day, (byDay.get(item.day) ?? 0) + n(item.count));
244+
for (const [day, visits] of byDay) days.push({ day, visits });
245+
}
246+
return {
247+
impressions: n(r.impressions),
248+
clicks: n(r.clicks),
249+
spent_cents: n(r.spent_cents),
250+
spend_today_cents: n(r.spend_today_cents),
251+
free_impressions: n(r.free_impressions),
252+
free_clicks: n(r.free_clicks),
253+
visits: { total: days.reduce((sum, d) => sum + d.visits, 0), days },
254+
};
255+
}
256+
257+
export async function patchCampaign(
258+
sb: SupabaseClient,
259+
userId: string,
260+
campaign: CampaignSummary,
261+
patch: CampaignPatch,
262+
): Promise<{ ok: true; campaign: CampaignSummary } | { ok: false; status: number; error: string }> {
263+
const update: Record<string, unknown> = {};
264+
if (patch.name !== undefined) update.name = patch.name;
265+
if (patch.dailyBudgetCents !== undefined) update.daily_budget_cents = patch.dailyBudgetCents;
266+
if (patch.bidCredits !== undefined) update.bid_credits = patch.bidCredits;
267+
if (patch.status !== undefined) {
268+
if (patch.status === "active") {
269+
// The dashboard's rule: nothing goes live without a creative to show.
270+
const { count } = await sb.from("ad_creatives").select("id", { count: "exact", head: true }).eq("campaign_id", campaign.id).eq("status", "ready");
271+
if (!count) return { ok: false, status: 409, error: "This campaign has no ready creative; add one in the dashboard before activating." };
272+
}
273+
update.status = patch.status;
274+
}
275+
const { data, error } = await sb.from("ad_campaigns").update(update).eq("id", campaign.id).eq("owner_id", userId).select(CAMPAIGN_COLUMNS).single();
276+
if (error || !data) return { ok: false, status: 500, error: error?.message ?? "Failed to update the campaign." };
277+
return { ok: true, campaign: data as CampaignSummary };
278+
}
279+
280+
/** Delete outright. Impressions and clicks cascade with it; pausing keeps them. */
281+
export async function deleteCampaign(sb: SupabaseClient, userId: string, campaign: CampaignSummary): Promise<{ ok: true } | { ok: false; status: number; error: string }> {
282+
const { error } = await sb.from("ad_campaigns").delete().eq("id", campaign.id).eq("owner_id", userId);
283+
if (error) return { ok: false, status: 500, error: error.message };
284+
return { ok: true };
285+
}

‎tests/ads-api-requests.test.ts‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,3 +80,20 @@ describe("crawlproof ads / slots CLI", () => {
8080
expect(slotBodyFromArgs(parseArgs(["slots", "create", "x.dev", "--formats=a,b", "--inactive"]))).toEqual({ site: "x.dev", formats: ["a", "b"], status: "inactive" });
8181
});
8282
});
83+
84+
describe("PATCH /api/ads/v1/campaigns/[id] body", () => {
85+
it("normalises a partial update and refuses an empty one", async () => {
86+
const { parseCampaignPatch, isRefSlug } = await import("@/lib/ads/campaign-request");
87+
expect(parseCampaignPatch({ status: "paused" })).toEqual({ ok: true, patch: { status: "paused" } });
88+
expect(parseCampaignPatch({ daily_budget_cents: 250.6, bid_credits: 900, name: " New " })).toEqual({
89+
ok: true,
90+
patch: { dailyBudgetCents: 251, bidCredits: 200, name: "New" },
91+
});
92+
expect(parseCampaignPatch({})).toMatchObject({ ok: false, error: expect.stringContaining("Nothing to change") });
93+
expect(parseCampaignPatch({ status: "exhausted" })).toMatchObject({ ok: false });
94+
expect(parseCampaignPatch({ name: "" })).toMatchObject({ ok: false });
95+
expect(parseCampaignPatch({ bid_credits: 0 })).toMatchObject({ ok: false });
96+
expect(isRefSlug("crawlproof-ad-144")).toBe(true);
97+
expect(isRefSlug("1a2fc904-a5b2-4c5d-a5bd-cda5ff471692")).toBe(false);
98+
});
99+
});

0 commit comments

Comments
 (0)