|
12 | 12 | // announced twice should not be paying for two campaigns. |
13 | 13 |
|
14 | 14 | 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"; |
16 | 16 |
|
17 | | -export { parseCampaignRequest, domainOf, type CampaignRequest, type CampaignStatus }; |
| 17 | +export { parseCampaignRequest, parseCampaignPatch, isRefSlug, domainOf, type CampaignRequest, type CampaignStatus, type CampaignPatch }; |
18 | 18 | import { getOrCreateDefaultOrg } from "@/lib/orgs"; |
19 | 19 | import { generateAdCreatives, cleanSummary, creativesFromCopy, templateCopy, summaryDomain, type AdCreative, type AdSummary } from "@/lib/ads/creative"; |
20 | 20 | import { extractSiteBrand, type SiteBrand } from "@/lib/ads/brand"; |
@@ -193,3 +193,93 @@ export async function listCampaigns(input: { sb: SupabaseClient; userId: string; |
193 | 193 | .limit(Math.min(200, Math.max(1, input.limit))); |
194 | 194 | return ((data as CampaignSummary[]) ?? []).map((c) => ({ ...c, dashboard_url: `${input.siteUrl}/dashboard/ads/${c.id}` })); |
195 | 195 | } |
| 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 | +} |
0 commit comments