Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions app/api/ads/v1/campaigns/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -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:<ref>) 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 = <T extends { id: string }>(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<string, unknown>;
try {
body = (await req.json()) as Record<string, unknown>;
} 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 });
}
63 changes: 61 additions & 2 deletions cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>,
): Promise<{ status: number; json: Record<string, unknown> }> {
Expand Down Expand Up @@ -265,6 +265,55 @@ async function cmdAds(args: Args): Promise<number> {
}
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} <ref-or-id>${sub === "budget" ? " <cents>" : ""}`);
return 2;
}
const path = `/api/ads/v1/campaigns/${encodeURIComponent(ref)}`;
let method: "GET" | "PATCH" | "DELETE" = "GET";
let body: Record<string, unknown> | 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 <ref-or-id> <cents per day>");
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<string, unknown> | 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)}`);
Expand All @@ -283,7 +332,7 @@ async function cmdAds(args: Args): Promise<number> {
}
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;
}

Expand Down Expand Up @@ -367,6 +416,16 @@ COMMANDS
ads list [--limit=20] [--json]
Your campaigns, newest first.

ads show <ref-or-id> [--json]
One campaign with its delivery: impressions, clicks, spend, and the
visits the tracker attributed to it on your own sites.

ads pause <ref-or-id> | ads resume <ref-or-id> | ads budget <ref-or-id> <cents>
Change a campaign in place. A ref looks like crawlproof-ad-144.

ads delete <ref-or-id> --yes
Remove it, metering included. Pause keeps the history.

slots create <site> [--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
Expand Down
39 changes: 39 additions & 0 deletions lib/ads/campaign-request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,42 @@ export function parseCampaignRequest(body: Record<string, unknown>): { 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<string, unknown>): { 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());
94 changes: 92 additions & 2 deletions lib/ads/campaigns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<CampaignSummary | null> {
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:<ref> visits on the caller's tracked sites. */
export async function campaignStats(sb: SupabaseClient, userId: string, campaign: CampaignSummary): Promise<CampaignStats> {
const { data: row } = await sb.from("ad_campaign_stats").select("*").eq("campaign_id", campaign.id).maybeSingle();
const r = (row as Record<string, unknown> | 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<string, number>();
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<string, unknown> = {};
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 };
}
17 changes: 17 additions & 0 deletions tests/ads-api-requests.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
Loading