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..d08ce897375b9 --- /dev/null +++ b/.github/issue-evidence/11596-ad-bid-strategy.md @@ -0,0 +1,33 @@ +# 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. +- `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 + +- `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..38a2e4bd5fba2 --- /dev/null +++ b/packages/cloud/shared/src/lib/services/__tests__/ad-campaign-bid-strategy.test.ts @@ -0,0 +1,303 @@ +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("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. + const findCampaign = 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), + ); + const deduct = track( + spyOn(creditsService, "deductCredits").mockResolvedValue({ + success: true, + transaction: { id: "tx-1" }, + } as never), + ); + 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 expect( + advertisingService.updateCampaign("campaign-1", ORG_ID, { + bidStrategy: "cpa", + optimizationGoal: "conversions", + }), + ).rejects.toThrow(/only be set at campaign creation/); + + expect(findCampaign).not.toHaveBeenCalled(); + expect(deduct).not.toHaveBeenCalled(); + expect(refund).not.toHaveBeenCalled(); + expect(updateOnProvider).not.toHaveBeenCalled(); + expect(updateRow).not.toHaveBeenCalled(); + }); +}); + +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..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"; @@ -55,6 +56,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)}`); @@ -151,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 // ============================================ @@ -559,6 +573,7 @@ class AdvertisingService { if (dayparting) { this.assertProviderCanApplyDayparting(account.platform); } + this.assertBidControlsSupported(account.platform, input); await contentSafetyService.assertSafeForPublicUse({ surface: "advertising_campaign", @@ -661,12 +676,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 @@ -731,6 +750,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 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", + ); + } + const campaign = await adCampaignsRepository.findById(campaignId); if (!campaign || campaign.organization_id !== organizationId) { throw new Error("Campaign not found"); 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"}
)}