Skip to content

feat(advertising): add campaign bid controls - #11621

Merged
lalalune merged 4 commits into
developfrom
feat/11596-ad-bid-strategy
Jul 3, 2026
Merged

lalalune merged 4 commits into
developfrom
feat/11596-ad-bid-strategy

Conversation

@lalalune

@lalalune lalalune commented Jul 2, 2026

Copy link
Copy Markdown
Member

Summary

  • Adds typed campaign bid controls (cpm/cpc/cpa) and optimization goals (reach/clicks/conversions) to advertising create/update schemas, DTOs, and campaign metadata.
  • Wires controls through the app promotion dialog and app-promotion service into campaign creation.
  • Maps controls into Meta ad set and Google campaign payloads; rejects TikTok campaign-level bid controls explicitly.

Fixes #11596.

Verification

  • bun install
  • bun test packages/cloud/shared/src/lib/services/__tests__/ad-campaign-bid-strategy.test.ts - 9 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
  • bun run --cwd packages/cloud/shared typecheck
  • bun run --cwd packages/cloud/api typecheck
  • bun run --cwd packages/ui typecheck
  • 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. The command exited 1 on unrelated minimalism ratchet failures in plugin-inbox-gui @ mobile-landscape and plugin-screenshare-gui @ mobile-portrait.

Evidence

  • .github/issue-evidence/11596-ad-bid-strategy.md

@coderabbitai

coderabbitai Bot commented Jul 2, 2026 •

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: a76928b1-b875-4068-8b66-24a9f61d90ba

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/11596-ad-bid-strategy

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@NubsCarson NubsCarson left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[cloud-audit] COMMENT — money controls hold; two non-blocking drift caveats.

Verified (money focus):

  • Spend gated by balance: bid controls add no new spend path. Budget is still charged via creditsService.deductCredits before provider.createCampaign (packages/cloud/shared/src/lib/services/advertising/index.ts:522-562 on develop), and budgetAmount remains z.number().positive() (schemas.ts:78). There is no numeric bid amount in this PR — bidStrategy/optimizationGoal are closed enums (CampaignBidStrategySchema = z.enum(["cpm","cpc","cpa"]), schemas.ts), so no negative/overflow bid is possible.
  • TikTok rejection refunds cleanly: validateTikTokCampaignBidControls returns success:false from inside provider.createCampaign, which lands in the existing full-refund branch (refundCredits(... createCampaign + budgetCredits ...), index.ts:583-585). No stranded credits.
  • Authorization: POST derives org from the authed user (organizationId: user.organization_id, campaigns/route.ts:80), never the body; PATCH/GET on [id]/route.ts:24 and service updateCampaign (index.ts:676) both enforce campaign.organization_id !== organizationId fail-closed.
  • Approval gate (#11516) intact: Meta ad set and Google campaign are still created status: "PAUSED" (meta.ts, google.ts:362), so bid controls cannot trigger spend before approval; the content-safety gate now also sees the bid text (index.ts:55-56).

Caveats (non-blocking):

  1. MED — PATCHed bid controls never reach the platform. The service persists bid_strategy/optimization_goal into metadata on update (index.ts:738-750), but metaAdsProvider.updateCampaign only sends name (meta.ts:369-371), Google's update ignores bid fields, and the TikTok validator is wired only into create (tiktok.ts:234). A user changing cpm to cpa gets an API success while the live campaign keeps billing the old way — stored state misrepresents real spend behavior. Suggest: either propagate on update or reject bid-control updates like TikTok create does.
  2. LOW — Google create now always injects a bidding strategy. With no controls selected, mapBidControlsToGoogleCampaign defaults to { manualCpm: {} } (google.ts:92-111), which Google rejects on SEARCH-channel campaigns (the default for traffic/awareness). Fail-closed with full refund, so no money loss, but search campaign creation may now error.
  3. LOW — stale-snapshot metadata merge in updateCampaign can clobber a concurrent sync's external_ad_set_ids/last_sync_at (index.ts:732-744); external_ad_set_ids feeds the delete path.

Tests cover schema rejection, provider mapping, persistence, and promotion pass-through (9 passing) — solid for the create path; the update-path drift above is untested because the behavior is missing.

[cloud-audit]

@lalalune

lalalune commented Jul 3, 2026

Copy link
Copy Markdown
Member Author

Lane verification (Fable-5): pushed b91a64439e adding status: "active" to the updateCampaign test's account mock — required for compatibility with the #11619 approval gate that merged after this branch was cut. In a test merge with origin/develop, the committed test failed with 'Ad account is not active (status: undefined)' (8/1); with the fix the merged tree runs 24 pass / 0 fail across ad-campaign-bid-strategy + ad-account-approval, and cloud/shared + cloud/api + ui typechecks exit 0. Spend invariants untouched (no budget math changed; TikTok adapter fail-closes on unsupported bid controls). Note: the 7 failures in ad-campaign-credit-reconciliation.test.ts visible in a merged run are a pre-existing develop red from #11619, fixed separately in #11715.

🤖 Generated with Claude Code

@lalalune

lalalune commented Jul 3, 2026

Copy link
Copy Markdown
Member Author

Review (worktree-verified on the merged tree, PR + develop tip): correct create-path implementation; one real defect found and fixed in 259e02eba73; branch updated with develop.

What holds up

  • Money paths are safe on create. Bid controls are closed enums (cpm|cpc|cpa / reach|clicks|conversions) with no numeric bid amount, so no new spend surface. When TikTok's new campaign-level rejection returns {success:false}, it fires before any TikTok API call, and the service's existing single-refund branch refunds createCampaign fee + budgetCredits exactly once — no cloud/money: #11493 sweep double-refunds in-flight app-chat holds (disjoint dedup key + grace < retry window) + markup over-refund #11683-class double-refund/double-spend is reachable (the !budgetDeduction.transaction and !result.success refund branches are mutually exclusive).
  • Bid math lives in use-cases, not the client. The dialog only selects enum values; mapping to billing_event/optimization_goal (Meta) and manualCpm|manualCpc|maximizeConversions (Google) happens in the provider layer. Meta's no-controls default is unchanged (IMPRESSIONS/REACH).
  • No migration. Persistence reuses the existing ad_campaigns.metadata jsonb (.notNull().default({})); the $type narrowing from string to the enums is safe — grep shows no pre-existing writer of bid_strategy/optimization_goal.
  • Tests are real. Mutation-checked: reverting the metadata persist reds the persistence test; reverting my update-guard reds the new rejection test.

Defect found + fixed (pushed as 259e02eba73)

The PATCH path persisted bid_strategy/optimization_goal into local metadata, but no adapter applies bid-control changes to a live campaign (Meta bid controls live on the ad set created at campaign creation; Google/TikTok updateCampaign only push name/budget/dates — verified in all three providers). So an update would report a strategy the platform never received — silent local/remote drift, and inconsistent with the PR's own explicit TikTok create-rejection. Worse, a combined {budgetAmount↑, bidStrategy} PATCH would move real credits for the budget change while silently no-opping the bid change.

Fix: updateCampaign now rejects bid-control changes fail-closed, before any credit movement or platform call (the issue's own acceptance criterion: "reject or record unsupported provider behavior explicitly"). Schema fields kept on UpdateCampaignSchema so clients get an explicit error instead of a zod-stripped silent ignore. Test replaced; asserts no deduct/refund/provider/repo side effects.

Verified locally (merged tree = PR + develop e59ddaf5338)

Notes for merge ordering

#11703 (dayparting), #11681 (attribution), #11658 (audience segments) all touch advertising/index.ts, schemas.ts, and the ad-campaigns.ts metadata $type block — whichever merges after this will need a small conflict resolve in those hunks.

Remaining acceptance-criteria gaps from #11596 (author-acknowledged in the evidence file): no route-level integration test, no scenario-runner trajectory, no live ad-platform run. The shipped surface is correct and unit-verified; those are evidence gaps, not correctness gaps.

@lalalune

lalalune commented Jul 3, 2026

Copy link
Copy Markdown
Member Author

Reviewed and pushed a fix commit: 15e46a8dc27 (fix(advertising): reject unsupported tiktok bid controls before charging). Leaving draft/unmerged.

Finding fixed:

  • The branch explicitly rejected TikTok bid controls, but the rejection happened inside the provider after createCampaign had already run content safety and deducted both creation + budget credits. That depended on refund compensation for a request we know up front cannot be applied.
  • The service now rejects TikTok bidStrategy / optimizationGoal immediately after ad-account approval and before content safety, credit debits, provider calls, or DB writes.
  • I also changed update-time bid-control rejection to use the shared ValidationError, so API callers get a 400 validation response instead of a generic internal error.

Validation run after the fix:

  • bun --config=/dev/null test packages/cloud/shared/src/lib/services/__tests__/ad-campaign-bid-strategy.test.ts — passed, 10 tests.
  • bun run --cwd packages/cloud/shared typecheck — passed.
  • bunx @biomejs/biome@2.5.2 check --files-ignore-unknown=true packages/cloud/shared/src/lib/services/advertising/index.ts packages/cloud/shared/src/lib/services/__tests__/ad-campaign-bid-strategy.test.ts — passed.
  • git diff --check origin/develop...HEAD && git diff --check — passed.

@lalalune
lalalune marked this pull request as ready for review July 3, 2026 01:18

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your trial has ended. Reactivate Greptile to resume code reviews.

lalalune and others added 4 commits July 2, 2026 21:21
…ampaign approval gate

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 <noreply@anthropic.com>
…losed

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 <noreply@anthropic.com>
@lalalune
lalalune force-pushed the feat/11596-ad-bid-strategy branch from 15e46a8 to 5f1e80c Compare July 3, 2026 01:22

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your trial has ended. Reactivate Greptile to resume code reviews.

@lalalune
lalalune merged commit c691ea0 into develop Jul 3, 2026
39 of 69 checks passed
@lalalune
lalalune deleted the feat/11596-ad-bid-strategy branch July 3, 2026 01:22
lalalune pushed a commit that referenced this pull request Jul 3, 2026
Adds `linkedin` to AdPlatform across schemas, DB typing, credit markup,
the provider registry, and app-promotion validation, plus a real
LinkedIn Marketing API provider (versioned REST gateway):

- adAccounts search finder for account discovery/validation
- campaign group + paused campaign creation with objective, budget,
  geo-targeting (urn:li:geo pass-through, worldwide default, loud
  failure on free-text locations), and #11621 bid-control mapping to
  costType/optimizationTargetType per the documented combinations
- Rest.li PARTIAL_UPDATE for update/pause/activate and
  PENDING_DELETION deletes
- Images/Videos API media upload (initializeUpload -> PUT ->
  finalizeUpload) owned by the account's organization reference
- inline dark-post creative creation (creatives?action=createInline)
- adAnalytics analytics-finder metrics mapping
- OAuth2 refresh_token grant support

Unit tests use fixtures lifted from the Microsoft Learn LinkedIn
Marketing API reference pages and drive advertisingService with the
real provider to prove the #11619 approval gate, #11621 bid metadata,
and the fail-closed refund path apply to LinkedIn automatically.
A credential-gated linkedin.real.test.ts live lane loud-skips without
LINKEDIN_ADS_ACCESS_TOKEN.

Closes #11663. Refs #11361.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@claude

claude Bot commented Jul 3, 2026 •

Copy link
Copy Markdown
Contributor

Claude encountered an error —— View job


I'll analyze this and get back to you.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Cloud advertising: bid strategy controls for campaigns (CPM/CPC/CPA) (#11363)

3 participants