From 06f02b6aced660810a7f06055e6dc2f92e2ee199 Mon Sep 17 00:00:00 2001 From: lalalune Date: Thu, 2 Jul 2026 14:59:50 -0700 Subject: [PATCH 1/4] feat(advertising): add campaign bid controls --- .../issue-evidence/11596-ad-bid-strategy.md | 32 ++ .../v1/advertising/campaigns/[id]/route.ts | 4 + .../api/v1/advertising/campaigns/route.ts | 6 + .../shared/src/db/schemas/ad-campaigns.ts | 14 +- .../ad-campaign-bid-strategy.test.ts | 274 ++++++++++++++++++ .../src/lib/services/advertising/index.ts | 32 +- .../services/advertising/providers/google.ts | 23 ++ .../services/advertising/providers/meta.ts | 35 ++- .../services/advertising/providers/tiktok.ts | 19 ++ .../src/lib/services/advertising/schemas.ts | 8 + .../src/lib/services/advertising/types.ts | 8 + .../shared/src/lib/services/app-promotion.ts | 10 +- .../promotion/promote-app-dialog.tsx | 95 +++++- 13 files changed, 548 insertions(+), 12 deletions(-) create mode 100644 .github/issue-evidence/11596-ad-bid-strategy.md create mode 100644 packages/cloud/shared/src/lib/services/__tests__/ad-campaign-bid-strategy.test.ts diff --git a/.github/issue-evidence/11596-ad-bid-strategy.md b/.github/issue-evidence/11596-ad-bid-strategy.md new file mode 100644 index 0000000000000..abddfc6534869 --- /dev/null +++ b/.github/issue-evidence/11596-ad-bid-strategy.md @@ -0,0 +1,32 @@ +# Issue #11596 — Advertising Bid Strategy Controls + +## Implementation + +- Added `bidStrategy` (`cpm`/`cpc`/`cpa`) and `optimizationGoal` (`reach`/`clicks`/`conversions`) to campaign create/update schemas and service types. +- Persisted bid controls in `ad_campaigns.metadata` and returned them from campaign list/detail/create API responses. +- Threaded bid controls through app promotion config and the promotion dialog. +- Mapped controls into Meta ad set and Google campaign create payloads. +- Explicitly rejects TikTok campaign-level bid controls because this adapter only has bid settings on the ad-group path today. + +## Verification + +- `bun test packages/cloud/shared/src/lib/services/__tests__/ad-campaign-bid-strategy.test.ts` + - 9 tests passed. +- `bunx biome check packages/cloud/shared/src/lib/services/__tests__/ad-campaign-bid-strategy.test.ts packages/cloud/shared/src/lib/services/app-promotion.ts packages/ui/src/cloud-ui/components/promotion/promote-app-dialog.tsx packages/cloud/shared/src/lib/services/advertising/providers/meta.ts packages/cloud/shared/src/lib/services/advertising/providers/google.ts packages/cloud/shared/src/lib/services/advertising/providers/tiktok.ts packages/cloud/shared/src/lib/services/advertising/index.ts packages/cloud/shared/src/lib/services/advertising/types.ts packages/cloud/shared/src/lib/services/advertising/schemas.ts packages/cloud/shared/src/db/schemas/ad-campaigns.ts packages/cloud/api/v1/advertising/campaigns/route.ts packages/cloud/api/v1/advertising/campaigns/[id]/route.ts` + - Passed. +- `bun run --cwd packages/cloud/shared typecheck` + - Passed. +- `bun run --cwd packages/cloud/api typecheck` + - Passed. +- `bun run --cwd packages/ui typecheck` + - Passed. +- `bun run --cwd packages/app audit:app` + - 348 captures passed. + - Touched `/apps` route verdicts were `good` for mobile portrait, mobile landscape, desktop landscape, and iPad portrait. + - Command exited 1 on unrelated existing minimalism ratchet failures: + - `plugin-inbox-gui @ mobile-landscape` + - `plugin-screenshare-gui @ mobile-portrait` + +## Evidence Gaps + +- No live ad-platform campaign was created. Provider behavior is covered with deterministic payload mapping tests; live external ad account spend remains the missing end-to-end evidence. diff --git a/packages/cloud/api/v1/advertising/campaigns/[id]/route.ts b/packages/cloud/api/v1/advertising/campaigns/[id]/route.ts index 995b25a1db905..e33c3e3b3f3fc 100644 --- a/packages/cloud/api/v1/advertising/campaigns/[id]/route.ts +++ b/packages/cloud/api/v1/advertising/campaigns/[id]/route.ts @@ -36,6 +36,8 @@ app.get("/", async (c) => { budgetType: campaign.budget_type, budgetAmount: campaign.budget_amount, budgetCurrency: campaign.budget_currency, + bidStrategy: campaign.metadata.bid_strategy, + optimizationGoal: campaign.metadata.optimization_goal, creditsAllocated: campaign.credits_allocated, creditsSpent: campaign.credits_spent, startDate: campaign.start_date?.toISOString(), @@ -77,6 +79,8 @@ app.patch("/", async (c) => { { name: parsed.data.name, budgetAmount: parsed.data.budgetAmount, + bidStrategy: parsed.data.bidStrategy, + optimizationGoal: parsed.data.optimizationGoal, startDate: parsed.data.startDate ? new Date(parsed.data.startDate) : undefined, diff --git a/packages/cloud/api/v1/advertising/campaigns/route.ts b/packages/cloud/api/v1/advertising/campaigns/route.ts index a93f4da7410ba..016620e4c9fe4 100644 --- a/packages/cloud/api/v1/advertising/campaigns/route.ts +++ b/packages/cloud/api/v1/advertising/campaigns/route.ts @@ -45,6 +45,8 @@ app.get("/", async (c) => { budgetType: c.budget_type, budgetAmount: c.budget_amount, budgetCurrency: c.budget_currency, + bidStrategy: c.metadata.bid_strategy, + optimizationGoal: c.metadata.optimization_goal, creditsAllocated: c.credits_allocated, creditsSpent: c.credits_spent, startDate: c.start_date?.toISOString(), @@ -85,6 +87,8 @@ app.post("/", async (c) => { budgetType: parsed.data.budgetType, budgetAmount: parsed.data.budgetAmount, budgetCurrency: parsed.data.budgetCurrency, + bidStrategy: parsed.data.bidStrategy, + optimizationGoal: parsed.data.optimizationGoal, startDate: parsed.data.startDate ? new Date(parsed.data.startDate) : undefined, @@ -108,6 +112,8 @@ app.post("/", async (c) => { status: campaign.status, budgetType: campaign.budget_type, budgetAmount: campaign.budget_amount, + bidStrategy: campaign.metadata.bid_strategy, + optimizationGoal: campaign.metadata.optimization_goal, creditsAllocated: campaign.credits_allocated, dayparting: campaign.metadata.dayparting ?? null, createdAt: campaign.created_at.toISOString(), diff --git a/packages/cloud/shared/src/db/schemas/ad-campaigns.ts b/packages/cloud/shared/src/db/schemas/ad-campaigns.ts index 7bcdc828324d5..0f9dcd2879b37 100644 --- a/packages/cloud/shared/src/db/schemas/ad-campaigns.ts +++ b/packages/cloud/shared/src/db/schemas/ad-campaigns.ts @@ -42,6 +42,16 @@ export type CampaignObjective = */ export type BudgetType = "daily" | "lifetime"; +/** + * Campaign bid strategy selected by the user. + */ +export type CampaignBidStrategy = "cpm" | "cpc" | "cpa"; + +/** + * Optimization goal selected by the user. + */ +export type CampaignOptimizationGoal = "reach" | "clicks" | "conversions"; + /** * Ad campaigns table schema. * @@ -116,8 +126,8 @@ export const adCampaigns = pgTable( .$type<{ external_ad_set_ids?: string[]; external_ad_ids?: string[]; - optimization_goal?: string; - bid_strategy?: string; + optimization_goal?: CampaignOptimizationGoal; + bid_strategy?: CampaignBidStrategy; dayparting?: { timezone: string; windows: Array<{ diff --git a/packages/cloud/shared/src/lib/services/__tests__/ad-campaign-bid-strategy.test.ts b/packages/cloud/shared/src/lib/services/__tests__/ad-campaign-bid-strategy.test.ts new file mode 100644 index 0000000000000..20ef38be9146e --- /dev/null +++ b/packages/cloud/shared/src/lib/services/__tests__/ad-campaign-bid-strategy.test.ts @@ -0,0 +1,274 @@ +import { afterEach, describe, expect, spyOn, test } from "bun:test"; +import { + adAccountsRepository, + adCampaignsRepository, + adTransactionsRepository, +} from "../../../db/repositories"; +import { advertisingService } from "../advertising"; +import { mapBidControlsToGoogleCampaign } from "../advertising/providers/google"; +import { mapBidControlsToMetaAdSet } from "../advertising/providers/meta"; +import { validateTikTokCampaignBidControls } from "../advertising/providers/tiktok"; +import { CreateCampaignSchema, UpdateCampaignSchema } from "../advertising/schemas"; +import type { AdProvider, CreateCampaignInput } from "../advertising/types"; +import { appPromotionService } from "../app-promotion"; +import { appsService } from "../apps"; +import { contentSafetyService } from "../content-safety"; +import { creditsService } from "../credits"; + +const ORG_ID = "00000000-0000-4000-8000-000000000001"; +const ACCOUNT_ID = "00000000-0000-4000-8000-000000000002"; + +const spies: Array<{ mockRestore: () => void }> = []; +function track void }>(s: T): T { + spies.push(s); + return s; +} + +function makeCreateInput(over: Partial = {}): CreateCampaignInput { + return { + organizationId: ORG_ID, + adAccountId: ACCOUNT_ID, + name: "Launch campaign", + objective: "traffic", + budgetType: "daily", + budgetAmount: 100, + budgetCurrency: "USD", + ...over, + }; +} + +function stubProvider(over: Partial = {}): AdProvider { + return { + platform: "meta", + createCampaign: async () => ({ success: true, externalCampaignId: "external-1" }), + updateCampaign: async () => ({ success: true }), + deleteCampaign: async () => ({ success: true }), + ...over, + } as unknown as AdProvider; +} + +afterEach(() => { + for (const s of spies.splice(0)) s.mockRestore(); +}); + +describe("ad campaign bid controls schemas", () => { + test("accept supported bid strategies and optimization goals", () => { + expect( + CreateCampaignSchema.parse({ + organizationId: ORG_ID, + adAccountId: ACCOUNT_ID, + name: "Launch campaign", + objective: "traffic", + budgetType: "daily", + budgetAmount: 100, + bidStrategy: "cpc", + optimizationGoal: "clicks", + }), + ).toMatchObject({ + bidStrategy: "cpc", + optimizationGoal: "clicks", + }); + + expect( + UpdateCampaignSchema.parse({ + bidStrategy: "cpa", + optimizationGoal: "conversions", + }), + ).toMatchObject({ + bidStrategy: "cpa", + optimizationGoal: "conversions", + }); + }); + + test("reject unsupported bid strategy values", () => { + expect(() => + CreateCampaignSchema.parse({ + organizationId: ORG_ID, + adAccountId: ACCOUNT_ID, + name: "Launch campaign", + objective: "traffic", + budgetType: "daily", + budgetAmount: 100, + bidStrategy: "roas", + }), + ).toThrow(); + }); +}); + +describe("ad provider bid-control mapping", () => { + test("maps Meta CPC/click controls onto ad set billing and optimization fields", () => { + expect( + mapBidControlsToMetaAdSet( + makeCreateInput({ bidStrategy: "cpc", optimizationGoal: "clicks" }), + ), + ).toEqual({ + billing_event: "LINK_CLICKS", + optimization_goal: "LINK_CLICKS", + }); + }); + + test("maps Meta CPA/conversion controls onto conversion optimization", () => { + expect( + mapBidControlsToMetaAdSet( + makeCreateInput({ bidStrategy: "cpa", optimizationGoal: "conversions" }), + ), + ).toEqual({ + billing_event: "IMPRESSIONS", + optimization_goal: "OFFSITE_CONVERSIONS", + }); + }); + + test("maps Google campaign controls to bidding strategy fields", () => { + expect(mapBidControlsToGoogleCampaign({ bidStrategy: "cpm" })).toEqual({ manualCpm: {} }); + expect(mapBidControlsToGoogleCampaign({ bidStrategy: "cpc" })).toEqual({ manualCpc: {} }); + expect(mapBidControlsToGoogleCampaign({ bidStrategy: "cpa" })).toEqual({ + maximizeConversions: {}, + }); + }); + + test("rejects TikTok campaign-level bid controls explicitly", () => { + expect(validateTikTokCampaignBidControls({ bidStrategy: "cpc" })).toEqual({ + success: false, + error: + "TikTok campaign creation does not support campaign-level bid strategy controls through this adapter", + }); + }); +}); + +describe("advertisingService bid controls persistence", () => { + test("createCampaign persists bid controls in campaign metadata and passes them to the provider", async () => { + track( + spyOn(adAccountsRepository, "findById").mockResolvedValue({ + id: ACCOUNT_ID, + organization_id: ORG_ID, + platform: "meta", + external_account_id: "act_1", + status: "active", + } as never), + ); + track(spyOn(contentSafetyService, "assertSafeForPublicUse").mockResolvedValue({} as never)); + track( + spyOn(creditsService, "deductCredits").mockResolvedValue({ + success: true, + transaction: { id: "tx-1" }, + } as never), + ); + track( + spyOn( + advertisingService as unknown as { getCredentials: () => Promise }, + "getCredentials", + ).mockResolvedValue({ accessToken: "token" } as never), + ); + + const provider = stubProvider(); + const createOnProvider = track(spyOn(provider, "createCampaign")); + track(spyOn(advertisingService, "getProvider").mockReturnValue(provider)); + const createRow = track( + spyOn(adCampaignsRepository, "create").mockImplementation(async (data) => data as never), + ); + track(spyOn(adTransactionsRepository, "create").mockResolvedValue({} as never)); + + const input = makeCreateInput({ + bidStrategy: "cpc", + optimizationGoal: "clicks", + }); + + await advertisingService.createCampaign(input); + + expect(createOnProvider).toHaveBeenCalledWith({ accessToken: "token" }, "act_1", input); + expect(createRow.mock.calls[0]?.[0]).toMatchObject({ + metadata: { + bid_strategy: "cpc", + optimization_goal: "clicks", + }, + }); + }); + + test("updateCampaign merges bid controls into existing metadata", async () => { + track( + spyOn(adCampaignsRepository, "findById").mockResolvedValue({ + id: "campaign-1", + organization_id: ORG_ID, + ad_account_id: ACCOUNT_ID, + external_campaign_id: "external-1", + name: "Launch campaign", + credits_allocated: "110", + budget_amount: "100", + metadata: { external_ad_set_ids: ["adset-1"] }, + } as never), + ); + track( + spyOn(adAccountsRepository, "findById").mockResolvedValue({ + id: ACCOUNT_ID, + organization_id: ORG_ID, + platform: "meta", + external_account_id: "act_1", + } as never), + ); + track( + spyOn( + advertisingService as unknown as { getCredentials: () => Promise }, + "getCredentials", + ).mockResolvedValue({ accessToken: "token" } as never), + ); + track(spyOn(advertisingService, "getProvider").mockReturnValue(stubProvider())); + const updateRow = track( + spyOn(adCampaignsRepository, "update").mockImplementation(async (_id, data) => data as never), + ); + + await advertisingService.updateCampaign("campaign-1", ORG_ID, { + bidStrategy: "cpa", + optimizationGoal: "conversions", + }); + + expect(updateRow.mock.calls[0]?.[1]).toMatchObject({ + metadata: { + external_ad_set_ids: ["adset-1"], + bid_strategy: "cpa", + optimization_goal: "conversions", + }, + }); + }); +}); + +describe("appPromotionService bid controls", () => { + test("passes promotion bid controls through to advertising campaign creation", async () => { + track( + spyOn(appsService, "getById").mockResolvedValue({ + id: "00000000-0000-4000-8000-000000000003", + organization_id: ORG_ID, + name: "Test App", + app_url: "https://example.test", + } as never), + ); + track( + spyOn(creditsService, "deductCredits").mockResolvedValue({ + success: false, + } as never), + ); + const createCampaign = track( + spyOn(advertisingService, "createCampaign").mockResolvedValue({ + id: "campaign-1", + name: "Test App - Promotion Campaign", + } as never), + ); + + await appPromotionService.promoteApp(ORG_ID, "user-1", "app-1", { + channels: ["advertising"], + advertising: { + platform: "meta", + adAccountId: ACCOUNT_ID, + budget: 100, + budgetType: "daily", + objective: "traffic", + bidStrategy: "cpc", + optimizationGoal: "clicks", + }, + }); + + expect(createCampaign.mock.calls[0]?.[0]).toMatchObject({ + bidStrategy: "cpc", + optimizationGoal: "clicks", + }); + }); +}); diff --git a/packages/cloud/shared/src/lib/services/advertising/index.ts b/packages/cloud/shared/src/lib/services/advertising/index.ts index 32993c291c2cc..5a84f85ae2799 100644 --- a/packages/cloud/shared/src/lib/services/advertising/index.ts +++ b/packages/cloud/shared/src/lib/services/advertising/index.ts @@ -55,6 +55,8 @@ class AdvertisingService { const text = [ "name" in input ? `Campaign name: ${input.name}` : undefined, "objective" in input && input.objective ? `Objective: ${input.objective}` : undefined, + input.bidStrategy ? `Bid strategy: ${input.bidStrategy}` : undefined, + input.optimizationGoal ? `Optimization goal: ${input.optimizationGoal}` : undefined, ]; if (input.targeting) { text.push(`Targeting: ${JSON.stringify(input.targeting)}`); @@ -661,12 +663,16 @@ class AdvertisingService { end_date: input.endDate, targeting: input.targeting || {}, app_id: input.appId, - metadata: dayparting - ? { - dayparting, - dayparting_provider_synced_at: new Date().toISOString(), - } - : {}, + metadata: { + ...(input.bidStrategy ? { bid_strategy: input.bidStrategy } : {}), + ...(input.optimizationGoal ? { optimization_goal: input.optimizationGoal } : {}), + ...(dayparting + ? { + dayparting, + dayparting_provider_synced_at: new Date().toISOString(), + } + : {}), + }, }); // Record budget allocation transaction @@ -836,6 +842,19 @@ class AdvertisingService { throw new Error(result.error || "Failed to update campaign"); } + const metadataUpdate = + input.bidStrategy !== undefined || input.optimizationGoal !== undefined + ? { + metadata: { + ...campaign.metadata, + ...(input.bidStrategy !== undefined ? { bid_strategy: input.bidStrategy } : {}), + ...(input.optimizationGoal !== undefined + ? { optimization_goal: input.optimizationGoal } + : {}), + }, + } + : {}; + const updateData = { name: input.name, budget_amount: input.budgetAmount ? String(input.budgetAmount) : undefined, @@ -845,6 +864,7 @@ class AdvertisingService { start_date: input.startDate, end_date: input.endDate, targeting: input.targeting, + ...metadataUpdate, }; let updated: AdCampaign | undefined; diff --git a/packages/cloud/shared/src/lib/services/advertising/providers/google.ts b/packages/cloud/shared/src/lib/services/advertising/providers/google.ts index 720612d82d53f..8f6f4460c04de 100644 --- a/packages/cloud/shared/src/lib/services/advertising/providers/google.ts +++ b/packages/cloud/shared/src/lib/services/advertising/providers/google.ts @@ -89,6 +89,28 @@ function mapObjectiveToGoogleAds(objective: string): { return mapping[objective] || { advertisingChannelType: "SEARCH" }; } +export function mapBidControlsToGoogleCampaign( + input: Pick, +): Record { + const effectiveGoal = + input.optimizationGoal ?? + (input.bidStrategy === "cpa" + ? "conversions" + : input.bidStrategy === "cpc" + ? "clicks" + : "reach"); + + if (effectiveGoal === "conversions") { + return { maximizeConversions: {} }; + } + + if (effectiveGoal === "clicks") { + return { manualCpc: {} }; + } + + return { manualCpm: {} }; +} + function splitGoogleCampaignId( accountId: string, externalCampaignId: string, @@ -361,6 +383,7 @@ export const googleAdsProvider: AdProvider = { advertisingChannelSubType: channelConfig.advertisingChannelSubType, status: "PAUSED", campaignBudget: budgetResourceName, + ...mapBidControlsToGoogleCampaign(input), startDate: input.startDate ? input.startDate.toISOString().split("T")[0].replace(/-/g, "") : undefined, diff --git a/packages/cloud/shared/src/lib/services/advertising/providers/meta.ts b/packages/cloud/shared/src/lib/services/advertising/providers/meta.ts index 266d536bbe823..63b71256a6bae 100644 --- a/packages/cloud/shared/src/lib/services/advertising/providers/meta.ts +++ b/packages/cloud/shared/src/lib/services/advertising/providers/meta.ts @@ -65,6 +65,38 @@ interface MetaAdImagesResponse { >; } +export function mapBidControlsToMetaAdSet(input: CreateCampaignInput): { + billing_event: string; + optimization_goal: string; +} { + const effectiveGoal = + input.optimizationGoal ?? + (input.bidStrategy === "cpa" + ? "conversions" + : input.bidStrategy === "cpc" + ? "clicks" + : "reach"); + + if (effectiveGoal === "conversions") { + return { + billing_event: "IMPRESSIONS", + optimization_goal: "OFFSITE_CONVERSIONS", + }; + } + + if (effectiveGoal === "clicks") { + return { + billing_event: "LINK_CLICKS", + optimization_goal: "LINK_CLICKS", + }; + } + + return { + billing_event: "IMPRESSIONS", + optimization_goal: "REACH", + }; +} + function isRetryableError(code: number): boolean { // Rate limit errors (code 4, 17, 32, 613) and temporary errors (code 1, 2) return [1, 2, 4, 17, 32, 613].includes(code); @@ -299,8 +331,7 @@ export const metaAdsProvider: AdProvider = { name: `${input.name} - Ad Set`, campaign_id: campaign.id, status: "PAUSED", - billing_event: "IMPRESSIONS", - optimization_goal: "REACH", + ...mapBidControlsToMetaAdSet(input), }; if (input.budgetType === "daily") { diff --git a/packages/cloud/shared/src/lib/services/advertising/providers/tiktok.ts b/packages/cloud/shared/src/lib/services/advertising/providers/tiktok.ts index 1f61d4b4692e6..d805a02e9dbe8 100644 --- a/packages/cloud/shared/src/lib/services/advertising/providers/tiktok.ts +++ b/packages/cloud/shared/src/lib/services/advertising/providers/tiktok.ts @@ -77,6 +77,20 @@ function mapObjectiveToTikTok(objective: string): string { return mapping[objective] || "TRAFFIC"; } +export function validateTikTokCampaignBidControls( + input: Pick, +): AdProviderCampaignResult | undefined { + if (!input.bidStrategy && !input.optimizationGoal) { + return undefined; + } + + return { + success: false, + error: + "TikTok campaign creation does not support campaign-level bid strategy controls through this adapter", + }; +} + function mapCtaToTikTok(cta?: string): string { const mapping: Record = { learn_more: "LEARN_MORE", @@ -217,6 +231,11 @@ export const tiktokAdsProvider: AdProvider = { objective: input.objective, }); + const bidControlError = validateTikTokCampaignBidControls(input); + if (bidControlError) { + return bidControlError; + } + const objective = mapObjectiveToTikTok(input.objective); // Convert budget to cents (TikTok uses smallest currency unit) diff --git a/packages/cloud/shared/src/lib/services/advertising/schemas.ts b/packages/cloud/shared/src/lib/services/advertising/schemas.ts index 3ed615c826e29..7c89a243444c1 100644 --- a/packages/cloud/shared/src/lib/services/advertising/schemas.ts +++ b/packages/cloud/shared/src/lib/services/advertising/schemas.ts @@ -14,6 +14,10 @@ export const CampaignObjectiveSchema = z.enum([ export const BudgetTypeSchema = z.enum(["daily", "lifetime"]); +export const CampaignBidStrategySchema = z.enum(["cpm", "cpc", "cpa"]); + +export const CampaignOptimizationGoalSchema = z.enum(["reach", "clicks", "conversions"]); + export const CreativeTypeSchema = z.enum(["image", "video", "carousel"]); export const CallToActionSchema = z.enum([ @@ -138,6 +142,8 @@ export const CreateCampaignSchema = z.object({ budgetType: BudgetTypeSchema, budgetAmount: z.number().positive(), budgetCurrency: z.string().length(3).optional(), + bidStrategy: CampaignBidStrategySchema.optional(), + optimizationGoal: CampaignOptimizationGoalSchema.optional(), startDate: z.string().datetime().optional(), endDate: z.string().datetime().optional(), targeting: TargetingSchema.optional(), @@ -148,6 +154,8 @@ export const CreateCampaignSchema = z.object({ export const UpdateCampaignSchema = z.object({ name: z.string().min(1).max(200).optional(), budgetAmount: z.number().positive().optional(), + bidStrategy: CampaignBidStrategySchema.optional(), + optimizationGoal: CampaignOptimizationGoalSchema.optional(), startDate: z.string().datetime().optional(), endDate: z.string().datetime().optional(), targeting: TargetingSchema.optional(), diff --git a/packages/cloud/shared/src/lib/services/advertising/types.ts b/packages/cloud/shared/src/lib/services/advertising/types.ts index f0bbb657065a1..b26cabf1dfc59 100644 --- a/packages/cloud/shared/src/lib/services/advertising/types.ts +++ b/packages/cloud/shared/src/lib/services/advertising/types.ts @@ -7,7 +7,9 @@ import type { AdAccountStatus, AdPlatform } from "../../../db/schemas/ad-accounts"; import type { BudgetType, + CampaignBidStrategy, CampaignObjective, + CampaignOptimizationGoal, CampaignStatus, } from "../../../db/schemas/ad-campaigns"; import type { CallToAction, CreativeStatus, CreativeType } from "../../../db/schemas/ad-creatives"; @@ -17,7 +19,9 @@ export type { AdPlatform, BudgetType, CallToAction, + CampaignBidStrategy, CampaignObjective, + CampaignOptimizationGoal, CampaignStatus, CreativeStatus, CreativeType, @@ -88,6 +92,8 @@ export interface CreateCampaignInput { budgetType: BudgetType; budgetAmount: number; budgetCurrency?: string; + bidStrategy?: CampaignBidStrategy; + optimizationGoal?: CampaignOptimizationGoal; startDate?: Date; endDate?: Date; targeting?: CampaignTargeting; @@ -98,6 +104,8 @@ export interface CreateCampaignInput { export interface UpdateCampaignInput { name?: string; budgetAmount?: number; + bidStrategy?: CampaignBidStrategy; + optimizationGoal?: CampaignOptimizationGoal; startDate?: Date; endDate?: Date; targeting?: CampaignTargeting; diff --git a/packages/cloud/shared/src/lib/services/app-promotion.ts b/packages/cloud/shared/src/lib/services/app-promotion.ts index ba091cd5b4a49..e888994824bac 100644 --- a/packages/cloud/shared/src/lib/services/app-promotion.ts +++ b/packages/cloud/shared/src/lib/services/app-promotion.ts @@ -17,7 +17,11 @@ import type { PostContent, SocialPlatform } from "../types/social-media"; import { extractErrorMessage } from "../utils/error-handling"; import { logger } from "../utils/logger"; import { advertisingService } from "./advertising"; -import type { AdPlatform } from "./advertising/types"; +import type { + AdPlatform, + CampaignBidStrategy, + CampaignOptimizationGoal, +} from "./advertising/types"; import { appsService } from "./apps"; import { creditsService } from "./credits"; import { discordAppAutomationService } from "./discord-automation/app-automation"; @@ -53,6 +57,8 @@ export interface PromotionConfig { budget: number; budgetType: "daily" | "lifetime"; objective: "awareness" | "traffic" | "engagement" | "app_promotion"; + bidStrategy?: CampaignBidStrategy; + optimizationGoal?: CampaignOptimizationGoal; duration?: number; targetLocations?: string[]; }; @@ -581,6 +587,8 @@ Return ONLY valid JSON, no markdown.`; objective: config.objective, budgetType: config.budgetType, budgetAmount: config.budget, + bidStrategy: config.bidStrategy, + optimizationGoal: config.optimizationGoal, startDate, endDate, appId: app.id, diff --git a/packages/ui/src/cloud-ui/components/promotion/promote-app-dialog.tsx b/packages/ui/src/cloud-ui/components/promotion/promote-app-dialog.tsx index c69dd2f8f1c14..c9f8e4a18807f 100644 --- a/packages/ui/src/cloud-ui/components/promotion/promote-app-dialog.tsx +++ b/packages/ui/src/cloud-ui/components/promotion/promote-app-dialog.tsx @@ -59,6 +59,8 @@ interface PromoteAppDialogProps { } type PromotionChannel = "social" | "seo" | "advertising"; +type CampaignBidStrategy = "cpm" | "cpc" | "cpa"; +type CampaignOptimizationGoal = "reach" | "clicks" | "conversions"; interface PromotionConfig { channels: PromotionChannel[]; @@ -77,6 +79,8 @@ interface PromotionConfig { budget: number; budgetType: "daily" | "lifetime"; objective: string; + bidStrategy?: CampaignBidStrategy; + optimizationGoal?: CampaignOptimizationGoal; duration?: number; }; } @@ -113,6 +117,21 @@ const AD_OBJECTIVES = [ }, ]; +const BID_STRATEGIES: Array<{ id: CampaignBidStrategy; name: string }> = [ + { id: "cpm", name: "CPM" }, + { id: "cpc", name: "CPC" }, + { id: "cpa", name: "CPA" }, +]; + +const OPTIMIZATION_GOALS: Array<{ + id: CampaignOptimizationGoal; + name: string; +}> = [ + { id: "reach", name: "Reach" }, + { id: "clicks", name: "Clicks" }, + { id: "conversions", name: "Conversions" }, +]; + export function PromoteAppDialog({ open, onOpenChange, @@ -138,6 +157,8 @@ export function PromoteAppDialog({ budget: 10, budgetType: "daily" as const, objective: "traffic", + bidStrategy: "cpm" as const, + optimizationGoal: "reach" as const, }, [adAccounts], ); @@ -639,6 +660,10 @@ export function PromoteAppDialog({ prev.advertising?.budgetType || "daily", objective: prev.advertising?.objective || "traffic", + bidStrategy: + prev.advertising?.bidStrategy || "cpm", + optimizationGoal: + prev.advertising?.optimizationGoal || "reach", }, })); }} @@ -691,6 +716,70 @@ export function PromoteAppDialog({ +
+ + +
+ +
+ + +
+
Ad Campaign: ${config.advertising?.budget}{" "} - {config.advertising?.budgetType} + {config.advertising?.budgetType},{" "} + {( + config.advertising?.bidStrategy || "cpm" + ).toUpperCase()}{" "} + for {config.advertising?.optimizationGoal || "reach"}
)} From 1c6a93ebbd4ebfead9535348a1ac9460d7091e27 Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Thu, 2 Jul 2026 17:10:28 -0700 Subject: [PATCH 2/4] =?UTF-8?q?test(advertising):=20mock=20ad=20account=20?= =?UTF-8?q?as=20active=20=E2=80=94=20satisfy=20#11619=20updateCampaign=20a?= =?UTF-8?q?pproval=20gate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The updateCampaign persistence test mocked adAccountsRepository.findById without a status field. After develop merged #11619 (updateCampaign now throws unless account.status === "active"), the merged tree fails this test with 'Ad account is not active (status: undefined)'. Mark the mock account active so the test exercises the metadata merge, not the gate. Co-Authored-By: Claude Fable 5 --- .../src/lib/services/__tests__/ad-campaign-bid-strategy.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/cloud/shared/src/lib/services/__tests__/ad-campaign-bid-strategy.test.ts b/packages/cloud/shared/src/lib/services/__tests__/ad-campaign-bid-strategy.test.ts index 20ef38be9146e..ec21f310c0e44 100644 --- a/packages/cloud/shared/src/lib/services/__tests__/ad-campaign-bid-strategy.test.ts +++ b/packages/cloud/shared/src/lib/services/__tests__/ad-campaign-bid-strategy.test.ts @@ -203,6 +203,7 @@ describe("advertisingService bid controls persistence", () => { organization_id: ORG_ID, platform: "meta", external_account_id: "act_1", + status: "active", } as never), ); track( From f28aaa8e68e205778db8ff80fe6bf04b11ea21be Mon Sep 17 00:00:00 2001 From: Shaw Date: Thu, 2 Jul 2026 20:50:52 -0400 Subject: [PATCH 3/4] fix(advertising): reject bid-control changes on updateCampaign fail-closed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No ad-platform adapter applies bid-control changes to a live campaign (Meta bid controls live on the ad set created with the campaign; Google/TikTok updateCampaign only push name/budget/dates). The previous update path persisted bid_strategy/optimization_goal into local campaign metadata anyway, so a PATCH would silently report a strategy the platform never received — local/remote drift hidden as success. updateCampaign now throws before any credit movement or platform call when bidStrategy/optimizationGoal are present, keeping the typed schema fields so clients get an explicit error instead of a zod-stripped silent ignore. Test replaced accordingly and asserts no deduct/refund/provider/ repo-update side effects fire (mutation-checked red without the guard). Co-Authored-By: Claude Fable 5 --- .../issue-evidence/11596-ad-bid-strategy.md | 1 + .../ad-campaign-bid-strategy.test.ts | 50 +++++++++---------- .../src/lib/services/advertising/index.ts | 24 ++++----- 3 files changed, 34 insertions(+), 41 deletions(-) diff --git a/.github/issue-evidence/11596-ad-bid-strategy.md b/.github/issue-evidence/11596-ad-bid-strategy.md index abddfc6534869..d08ce897375b9 100644 --- a/.github/issue-evidence/11596-ad-bid-strategy.md +++ b/.github/issue-evidence/11596-ad-bid-strategy.md @@ -7,6 +7,7 @@ - Threaded bid controls through app promotion config and the promotion dialog. - Mapped controls into Meta ad set and Google campaign create payloads. - Explicitly rejects TikTok campaign-level bid controls because this adapter only has bid settings on the ad-group path today. +- `updateCampaign` rejects bid-control changes fail-closed (before any credit movement or platform call): no adapter applies bid changes to a live campaign, so persisting them locally would be silent local/platform drift. ## Verification diff --git a/packages/cloud/shared/src/lib/services/__tests__/ad-campaign-bid-strategy.test.ts b/packages/cloud/shared/src/lib/services/__tests__/ad-campaign-bid-strategy.test.ts index ec21f310c0e44..c87a16d75efa9 100644 --- a/packages/cloud/shared/src/lib/services/__tests__/ad-campaign-bid-strategy.test.ts +++ b/packages/cloud/shared/src/lib/services/__tests__/ad-campaign-bid-strategy.test.ts @@ -184,8 +184,10 @@ describe("advertisingService bid controls persistence", () => { }); }); - test("updateCampaign merges bid controls into existing metadata", async () => { - track( + test("updateCampaign rejects bid-control changes before touching money or the platform", async () => { + // No adapter applies bid-control changes to a live campaign, so the + // service must fail closed instead of persisting metadata drift. + const findCampaign = track( spyOn(adCampaignsRepository, "findById").mockResolvedValue({ id: "campaign-1", organization_id: ORG_ID, @@ -197,38 +199,32 @@ describe("advertisingService bid controls persistence", () => { metadata: { external_ad_set_ids: ["adset-1"] }, } as never), ); - track( - spyOn(adAccountsRepository, "findById").mockResolvedValue({ - id: ACCOUNT_ID, - organization_id: ORG_ID, - platform: "meta", - external_account_id: "act_1", - status: "active", + const deduct = track( + spyOn(creditsService, "deductCredits").mockResolvedValue({ + success: true, + transaction: { id: "tx-1" }, } as never), ); - track( - spyOn( - advertisingService as unknown as { getCredentials: () => Promise }, - "getCredentials", - ).mockResolvedValue({ accessToken: "token" } as never), - ); - track(spyOn(advertisingService, "getProvider").mockReturnValue(stubProvider())); + const refund = track(spyOn(creditsService, "refundCredits").mockResolvedValue({} as never)); + const provider = stubProvider(); + const updateOnProvider = track(spyOn(provider, "updateCampaign")); + track(spyOn(advertisingService, "getProvider").mockReturnValue(provider)); const updateRow = track( spyOn(adCampaignsRepository, "update").mockImplementation(async (_id, data) => data as never), ); - await advertisingService.updateCampaign("campaign-1", ORG_ID, { - bidStrategy: "cpa", - optimizationGoal: "conversions", - }); + await expect( + advertisingService.updateCampaign("campaign-1", ORG_ID, { + bidStrategy: "cpa", + optimizationGoal: "conversions", + }), + ).rejects.toThrow(/only be set at campaign creation/); - expect(updateRow.mock.calls[0]?.[1]).toMatchObject({ - metadata: { - external_ad_set_ids: ["adset-1"], - bid_strategy: "cpa", - optimization_goal: "conversions", - }, - }); + expect(findCampaign).not.toHaveBeenCalled(); + expect(deduct).not.toHaveBeenCalled(); + expect(refund).not.toHaveBeenCalled(); + expect(updateOnProvider).not.toHaveBeenCalled(); + expect(updateRow).not.toHaveBeenCalled(); }); }); diff --git a/packages/cloud/shared/src/lib/services/advertising/index.ts b/packages/cloud/shared/src/lib/services/advertising/index.ts index 5a84f85ae2799..29a55270b9142 100644 --- a/packages/cloud/shared/src/lib/services/advertising/index.ts +++ b/packages/cloud/shared/src/lib/services/advertising/index.ts @@ -737,6 +737,16 @@ class AdvertisingService { organizationId: string, input: UpdateCampaignInput, ): Promise { + // No ad-platform adapter applies bid-control changes to a live campaign + // (Meta bid controls live on the ad set created with the campaign; + // Google/TikTok updates only push name/budget/dates). Reject explicitly + // instead of persisting local metadata the platform never receives. + if (input.bidStrategy !== undefined || input.optimizationGoal !== undefined) { + throw new Error( + "Bid strategy and optimization goal can only be set at campaign creation; ad platform adapters do not apply bid-control changes to live campaigns", + ); + } + const campaign = await adCampaignsRepository.findById(campaignId); if (!campaign || campaign.organization_id !== organizationId) { throw new Error("Campaign not found"); @@ -842,19 +852,6 @@ class AdvertisingService { throw new Error(result.error || "Failed to update campaign"); } - const metadataUpdate = - input.bidStrategy !== undefined || input.optimizationGoal !== undefined - ? { - metadata: { - ...campaign.metadata, - ...(input.bidStrategy !== undefined ? { bid_strategy: input.bidStrategy } : {}), - ...(input.optimizationGoal !== undefined - ? { optimization_goal: input.optimizationGoal } - : {}), - }, - } - : {}; - const updateData = { name: input.name, budget_amount: input.budgetAmount ? String(input.budgetAmount) : undefined, @@ -864,7 +861,6 @@ class AdvertisingService { start_date: input.startDate, end_date: input.endDate, targeting: input.targeting, - ...metadataUpdate, }; let updated: AdCampaign | undefined; From 5f1e80ca80d384ab14a13c5db3946d307fac0ca2 Mon Sep 17 00:00:00 2001 From: Shaw Date: Thu, 2 Jul 2026 21:17:48 -0400 Subject: [PATCH 4/4] fix(advertising): reject unsupported tiktok bid controls before charging --- .../ad-campaign-bid-strategy.test.ts | 32 +++++++++++++++++++ .../src/lib/services/advertising/index.ts | 15 ++++++++- 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/packages/cloud/shared/src/lib/services/__tests__/ad-campaign-bid-strategy.test.ts b/packages/cloud/shared/src/lib/services/__tests__/ad-campaign-bid-strategy.test.ts index c87a16d75efa9..38a2e4bd5fba2 100644 --- a/packages/cloud/shared/src/lib/services/__tests__/ad-campaign-bid-strategy.test.ts +++ b/packages/cloud/shared/src/lib/services/__tests__/ad-campaign-bid-strategy.test.ts @@ -184,6 +184,38 @@ describe("advertisingService bid controls persistence", () => { }); }); + test("createCampaign rejects TikTok bid controls before safety review, credits, or provider calls", async () => { + track( + spyOn(adAccountsRepository, "findById").mockResolvedValue({ + id: ACCOUNT_ID, + organization_id: ORG_ID, + platform: "tiktok", + external_account_id: "act_1", + status: "active", + } as never), + ); + const safety = track(spyOn(contentSafetyService, "assertSafeForPublicUse")); + const deduct = track(spyOn(creditsService, "deductCredits")); + const provider = stubProvider({ platform: "tiktok" }); + const createOnProvider = track(spyOn(provider, "createCampaign")); + track(spyOn(advertisingService, "getProvider").mockReturnValue(provider)); + + await expect( + advertisingService.createCampaign( + makeCreateInput({ + bidStrategy: "cpc", + optimizationGoal: "clicks", + }), + ), + ).rejects.toThrow( + "TikTok campaign creation does not support campaign-level bid strategy controls", + ); + + expect(safety).not.toHaveBeenCalled(); + expect(deduct).not.toHaveBeenCalled(); + expect(createOnProvider).not.toHaveBeenCalled(); + }); + test("updateCampaign rejects bid-control changes before touching money or the platform", async () => { // No adapter applies bid-control changes to a live campaign, so the // service must fail closed instead of persisting metadata drift. diff --git a/packages/cloud/shared/src/lib/services/advertising/index.ts b/packages/cloud/shared/src/lib/services/advertising/index.ts index 29a55270b9142..354b528d2b4d2 100644 --- a/packages/cloud/shared/src/lib/services/advertising/index.ts +++ b/packages/cloud/shared/src/lib/services/advertising/index.ts @@ -7,6 +7,7 @@ import { adCreativesRepository, adTransactionsRepository, } from "../../../db/repositories"; +import { ValidationError } from "../../api/cloud-worker-errors"; import { logger } from "../../utils/logger"; import { type ContentSafetyReview, contentSafetyService } from "../content-safety"; import { creditsService } from "../credits"; @@ -153,6 +154,17 @@ class AdvertisingService { return provider; } + private assertBidControlsSupported( + platform: AdPlatform, + input: Pick, + ): void { + if ((input.bidStrategy || input.optimizationGoal) && platform === "tiktok") { + throw ValidationError( + "TikTok campaign creation does not support campaign-level bid strategy controls through this adapter", + ); + } + } + // ============================================ // Credential Management // ============================================ @@ -561,6 +573,7 @@ class AdvertisingService { if (dayparting) { this.assertProviderCanApplyDayparting(account.platform); } + this.assertBidControlsSupported(account.platform, input); await contentSafetyService.assertSafeForPublicUse({ surface: "advertising_campaign", @@ -742,7 +755,7 @@ class AdvertisingService { // Google/TikTok updates only push name/budget/dates). Reject explicitly // instead of persisting local metadata the platform never receives. if (input.bidStrategy !== undefined || input.optimizationGoal !== undefined) { - throw new Error( + throw ValidationError( "Bid strategy and optimization goal can only be set at campaign creation; ad platform adapters do not apply bid-control changes to live campaigns", ); }