Skip to main content

Post Promotions — Technical Reference

← Back to Post Promotions

This doc was substantially rewritten twice. First pass: the original version described a pre-implementation plan (Stripe-charged USD budgets, adminReviewPromotion, myPostPromotions, postPromotionAnalytics with ROI/daily breakdown, trackPromotionImpression/trackPromotionClick mutations) that didn't match what was actually built — the real implementation charges coins and uses different operation names. Second pass (this one): the creator-facing frontend described below as missing has now been built.

Where this lives

Backend

Frontend

  • Creator-facing (apps/frontend-nextjs): built this pass.
    • apps/frontend-nextjs/src/components/CreatePromotionModal.tsx — campaign creation form: name, objective, budget type, budget amount (coins, with a live myCoinBalance check and the 50-coin minimum enforced client-side), start/end dates. Deliberately has no audience-targeting fields, since that data is inert on the backend (see below) — showing a targeting UI would imply a capability that doesn't exist.
    • apps/frontend-nextjs/src/page-components/settings/MyPromotionsPage.tsx (routed at /settings/promotions) — lists campaigns via myPromotions, per-campaign stats via promotionStats, and status-appropriate Pause/Resume/Cancel actions.
    • apps/frontend-nextjs/src/components/PostOptionsMenu.tsx — adds an owner-gated "Promote post" / "View promotion" entry, backed by a postPromotion(postId) query that's gated behind the menu's open state (not eager on every card render, to avoid a query per post on a page listing many owned posts).
    • PostCard.tsx still separately renders the read-only "Promoted" badge (isPromoted, next to the timestamp) for anyone viewing a post with an active promotion. PostModal.tsx doesn't render this badge — it only shows the sponsor/branded-content label and CTA, not promotion status.
  • Admin review queue (apps/frontend-admin): real. apps/frontend-admin/src/app/promotions/page.tsx — a working approve/reject queue, gated on MANAGE_PROMOTIONS. See Post Promotions Review.

Technical implementation checklist

  • createPostPromotion — real manager logic: charges the budget in coins immediately (minimum 50 coins), sets status pending_review, enforces one in-flight promotion per post (server-side guard only blocks pending_review/active duplicates, not paused — the frontend also treats paused as "has a promotion" to avoid confusing double-campaign UX). Now wired to CreatePromotionModal.tsx.
  • Branded content — Post.sponsor + PostCreateInput.sponsorUserId / actionButtonText / actionButtonUrl; renders paid-partnership label + CTA in PostCard.tsx / PostModal.tsx (migration 20260721110000-add-sponsor-to-post)
  • promoteLiveStream(liveStreamId) — creates/reuses an announcement post for a live and runs it through the promotion flow
  • updatePostPromotion / pausePostPromotion / resumePostPromotion / cancelPostPromotion — real state-machine transitions; cancelPostPromotion (and admin rejection) refund unspent budget via real coin-transaction records. updatePostPromotion isn't used by the new frontend (it only allows editing campaignName/targetAudience, and only pre-review — not useful enough to build a dedicated edit UI for yet); pause/resume/cancel are wired to MyPromotionsPage.tsx.
  • promotionStats — CTR, CPC, CPM, and daysRemaining are computed from real fields and shown on MyPromotionsPage.tsx. ROI and a daily time-series breakdown still don't exist in the schema — never built.
  • estimatePromotionReach — now a real estimate derived from the promoter's follower count, CTR from their recent post engagement, and the promotion's budget/duration/targeting breadth. A new account with no followers and no budget estimates ~0 (no more hardcoded 5000/15000/300).
  • targetAudience (location/demographics/interests JSON) — stored on create/update but never read or applied anywhere. Purely inert — deliberately not exposed in the new create form.
  • trackPromotionImpression / trackPromotionClick — the manager's trackImpression/trackClick methods are now exposed as mutations and actually called: PostCard.tsx fires trackPromotionImpression once per active sponsored post shown (guarded via a ref so re-renders don't double-count) and trackPromotionClick when its CTA is tapped. Both are soft no-ops server-side (return false instead of throwing on any problem, including unauthenticated). impressions/clicks on the model now increment in practice, feeding promotionStats' CTR/CPC/CPM. spentAmount still isn't decremented by anything — there's no per-impression/per-click coin cost model yet.
  • myPromotions / postPromotion(postId) / promotionById(id) — real read queries; myPromotions and postPromotion are now consumed (MyPromotionsPage.tsx and PostOptionsMenu.tsx respectively). promotionById has no direct frontend caller yet.
  • adminApprovePromotion / adminRejectPromotion / adminGetPendingPromotions — real, and wired to a real admin review page. Approving flips the campaign to active, which is the only status that gets the feed-ranking boost. See Post Promotions Review.

Campaign model (as actually implemented)

FieldDescription
id / postIdCampaign ID / the post being promoted
campaignNameDisplay name
objectiveCampaign goal: reach, engagement, traffic, conversions, or brand_awareness (plain STRING(50), not a GraphQL enum)
budgetTypedaily or lifetime
budgetAmountCoins, not USD — charged up front on creation (min 50)
spentAmountCoins spent so far — not actually decremented by anything yet (no per-impression/per-click coin cost model exists)
targetAudienceJSON — stored, never applied
statuspending_review, active, paused, completed, rejected, cancelled (the model column also defaults to draft, but createPromotion always creates rows directly in pending_reviewdraft is never actually produced)
startsAt / endsAtCampaign window
impressions / clicksIncrement via trackPromotionImpression/trackPromotionClick, fired from PostCard.tsx — see above
cpm / cpc / ctrComputed by promotionStats from whatever impressions/clicks/spentAmount happen to hold

GraphQL API

Create and manage a campaign

mutation CreatePostPromotion($input: CreatePostPromotionInput!) {
createPostPromotion(input: $input) { id campaignName status startsAt endsAt budgetAmount }
}

mutation UpdatePostPromotion($id: ID!, $input: UpdatePostPromotionInput!) {
updatePostPromotion(id: $id, input: $input) { id status }
}

# pause/resume/cancel all return the updated PostPromotion, not a success wrapper
mutation PausePostPromotion($id: ID!) { pausePostPromotion(id: $id) { id status } }
mutation ResumePostPromotion($id: ID!) { resumePostPromotion(id: $id) { id status } }

# Ends the campaign and refunds unspent budget in coins
mutation CancelPostPromotion($id: ID!) { cancelPostPromotion(id: $id) { id status } }

# Fired from PostCard.tsx for active sponsored posts - soft no-ops, return false rather than throwing
mutation TrackPromotionImpression($promotionId: ID!) { trackPromotionImpression(promotionId: $promotionId) }
mutation TrackPromotionClick($promotionId: ID!) { trackPromotionClick(promotionId: $promotionId) }

Reading campaigns and stats

query MyPromotions($status: String) {
myPromotions(status: $status) { id campaignName status budgetAmount spentAmount startsAt endsAt }
}

query PostPromotion($postId: ID!) { postPromotion(postId: $postId) { id status } }
query PromotionById($id: ID!) { promotionById(id: $id) { id campaignName status } }

query PromotionStats($id: ID!) {
promotionStats(id: $id) { impressions clicks cpm cpc ctr spentAmount daysRemaining }
}

# Real estimate now, derived from the caller's follower count/engagement/budget -
# still ignores location/demographics/interests, since targetAudience is inert
query EstimateReach($targetAudience: JSON) {
estimatePromotionReach(targetAudience: $targetAudience) {
minReach maxReach estimatedReach estimatedImpressions estimatedClicks estimatedCtr
}
}

Admin review

query AdminGetPendingPromotions { adminGetPendingPromotions { id campaignName objective budgetType budgetAmount user { username } } }

# Both return the updated PostPromotion, not a success wrapper
mutation AdminApprovePromotion($id: ID!) { adminApprovePromotion(id: $id) { id status reviewedAt } }
mutation AdminRejectPromotion($id: ID!, $reason: String) { adminRejectPromotion(id: $id, reason: $reason) { id status rejectionReason reviewedAt } }

See Post Promotions Review for the admin-panel side.