Post Promotions — Technical Reference
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
apps/backend/graphql/types/post-promotion.type.js— real schema, wired into the live (non-admin) GraphQL APIapps/backend/graphql/resolvers/post-promotion.resolver.js— resolvers for all non-admin operations, plus the two admin operations (adminApprovePromotion/adminRejectPromotion, gated onMANAGE_PROMOTIONS)apps/backend/managers/post-managers/post-promotion.manager.js(~635 lines) — real, non-stubbed campaign lifecycle logicapps/backend/data-access-services/post/post-promotion.access-service.js—PostPromotiondatabase queries- Feed ranking:
apps/backend/data-access-services/post/post.access-service.js(getFeed, around theWEIGHTS.promotionline) givesactivepromotions a0.15weight boost in home-feed ranking — this is the one place a promotion has a real, visible effect on the product beyond the campaign record itself.
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 livemyCoinBalancecheck 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 viamyPromotions, per-campaign stats viapromotionStats, 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 apostPromotion(postId)query that's gated behind the menu'sopenstate (not eager on every card render, to avoid a query per post on a page listing many owned posts).PostCard.tsxstill separately renders the read-only "Promoted" badge (isPromoted, next to the timestamp) for anyone viewing a post with an active promotion.PostModal.tsxdoesn'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 onMANAGE_PROMOTIONS. See Post Promotions Review.
Technical implementation checklist
-
createPostPromotion— real manager logic: charges the budget in coins immediately (minimum 50 coins), sets statuspending_review, enforces one in-flight promotion per post (server-side guard only blockspending_review/activeduplicates, notpaused— the frontend also treatspausedas "has a promotion" to avoid confusing double-campaign UX). Now wired toCreatePromotionModal.tsx. - Branded content —
Post.sponsor+PostCreateInput.sponsorUserId/actionButtonText/actionButtonUrl; renders paid-partnership label + CTA inPostCard.tsx/PostModal.tsx(migration20260721110000-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 realcoin-transactionrecords.updatePostPromotionisn't used by the new frontend (it only allows editingcampaignName/targetAudience, and only pre-review — not useful enough to build a dedicated edit UI for yet); pause/resume/cancel are wired toMyPromotionsPage.tsx. -
promotionStats— CTR, CPC, CPM, anddaysRemainingare computed from real fields and shown onMyPromotionsPage.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'strackImpression/trackClickmethods are now exposed as mutations and actually called:PostCard.tsxfirestrackPromotionImpressiononce per active sponsored post shown (guarded via a ref so re-renders don't double-count) andtrackPromotionClickwhen its CTA is tapped. Both are soft no-ops server-side (returnfalseinstead of throwing on any problem, including unauthenticated).impressions/clickson the model now increment in practice, feedingpromotionStats' CTR/CPC/CPM.spentAmountstill isn't decremented by anything — there's no per-impression/per-click coin cost model yet. -
myPromotions/postPromotion(postId)/promotionById(id)— real read queries;myPromotionsandpostPromotionare now consumed (MyPromotionsPage.tsxandPostOptionsMenu.tsxrespectively).promotionByIdhas no direct frontend caller yet. -
adminApprovePromotion/adminRejectPromotion/adminGetPendingPromotions— real, and wired to a real admin review page. Approving flips the campaign toactive, which is the only status that gets the feed-ranking boost. See Post Promotions Review.
Campaign model (as actually implemented)
| Field | Description |
|---|---|
id / postId | Campaign ID / the post being promoted |
campaignName | Display name |
objective | Campaign goal: reach, engagement, traffic, conversions, or brand_awareness (plain STRING(50), not a GraphQL enum) |
budgetType | daily or lifetime |
budgetAmount | Coins, not USD — charged up front on creation (min 50) |
spentAmount | Coins spent so far — not actually decremented by anything yet (no per-impression/per-click coin cost model exists) |
targetAudience | JSON — stored, never applied |
status | pending_review, active, paused, completed, rejected, cancelled (the model column also defaults to draft, but createPromotion always creates rows directly in pending_review — draft is never actually produced) |
startsAt / endsAt | Campaign window |
impressions / clicks | Increment via trackPromotionImpression/trackPromotionClick, fired from PostCard.tsx — see above |
cpm / cpc / ctr | Computed 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.