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); + }); +});