Exclusive Posts (Post Purchases) — Technical Reference
← Back to Exclusive Posts (Post Purchases)
Where this lives
Backend
apps/backend/managers/post-managers/post-purchase.manager.js— purchase, refund, and access-verification logic (purchasePost,verifyAccess,refundPurchase,getPurchaseStats,getCreatorEarnings,getCreatorSales)apps/backend/data-access-services/post/post-purchase.access-service.js— purchase record DB accessapps/backend/graphql/types/post-purchase.type.js/post-purchase.resolver.js— the GraphQL layer described belowapps/backend/graphql/types/post.type.js—Post.isPaid/Post.coinPrice/Post.originalCoinPrice/Post.purchaseCount/Post.hasPostAccess/Post.hiddenMediaCountfields, andPostMedia.isPreview/PostMediaInput.isPreviewapps/backend/validators/post.validator.js—coinPricevalidation on create/update;isPaidis always derived server-side fromcoinPrice > 0, never accepted directly from the client
Frontend
apps/frontend-nextjs/src/components/CreatePostModal.tsx— "Make exclusive" price toggle next to the visibility picker, plus a per-photo "mark as free preview" toggle on each thumbnailapps/frontend-nextjs/src/components/PostCard.tsx/PostModal.tsx— the lock overlay, "Unlock for N coins" button + confirm dialog, and the "+N locked" hidden-media hint;purchasePostAccessis called from hereapps/frontend-nextjs/src/page-components/PublicProfilePage.tsx— exclusive posts render inline in the normal profile grid with a coin-price badge (opening one goes throughPostCard/PostModalabove); paid group chats have their own separate "paidChats" tab now, not shared with exclusive posts (see Profile and Creator Subscriptions)apps/frontend-nextjs/src/page-components/settings/CreatorSalesPage.tsx—Settings → Sales & earnings: "Sales" tab (myPostPurchaseEarnings+myPostPurchaseSales) and "My purchases" tab (myPurchasedPosts)apps/frontend-nextjs/src/page-components/PostInsightsPage.tsx— per-post purchase stats (postPurchaseStats) shown alongside views/likes/comments/sharesapps/frontend-admin/src/app/moderation/refunds/page.tsx— admin refunds queue (adminGetPostPurchases/adminRefundPostPurchase)
Technical implementation checklist
-
coinPricepost pricing — exposed onPostCreateInput/PostUpdateInput;isPaidis computed server-side from it -
purchasePostAccess— wired; debits the buyer, credits the seller 90%, creates aPostPurchaserow, incrementsPost.purchaseCount, notifies the seller -
hasPostAccess— both a standalonehasPostAccess(postId)query and aPost.hasPostAccessfield resolver are wired (the field resolver is what post cards/modals actually use, to avoid an extra round-trip per post) -
myPostPurchaseSales/myPostPurchaseEarnings— wired on the backend and consumed by the "Sales" tab ofSettings → Sales & earnings(CreatorSalesPage.tsx).postPurchaseStats(per-post stats) is consumed byPostInsightsPage.tsx -
myPurchasedPosts/refundPostPurchase—myPurchasedPostsbacks the "My purchases" tab of the sameCreatorSalesPage.tsx.refundPostPurchaseis the buyer-facing self-service refund, gated oncontext.user. Fixed this session: it previously requiredcontext.admin, which the client schema never populates, making it permanently unreachable from any real client request; it now checks the caller is the purchase's actual buyer (purchase.buyerId === user.userId) and that the request falls within a 24hBUYER_REFUND_WINDOW_MSofPostPurchase.createdAt, throwingpost_purchase.not_owner/post_purchase.refund_window_expiredotherwise. The admin panel still uses the separate, unbounded admin-schema equivalentsadminGetPostPurchases/adminRefundPostPurchaseon/moderation/refunds, gated byMODERATE_CONTENT/super_admininstead of a buyer/window check - Frontend paywall/unlock UI — exclusive posts render inline in the normal feed/profile grid with a coin-price badge, not a dedicated profile tab. Opening a locked post shows a lock overlay, an "Unlock for N coins" button with a confirm step, and a "+N locked" hidden-media hint, all in
PostCard.tsx/PostModal.tsx - Per-photo free previews —
isPreviewonPostMediaInput/PostMedialets a creator mark specific photos of a paid post as free;Post.mediareturns just those items to a locked viewer instead ofnull, andPost.hiddenMediaCountreports how many remain hidden - Price-drop bookkeeping — lowering
coinPriceon an already-paid post stores the prior price asPost.originalCoinPrice(post.manager.js#updatePost) so the client can show it struck-through; raising or clearing the price resetsoriginalCoinPricetonull
A real bug found and fixed while wiring this up
Before this work, every method in post-purchase.manager.js and post-purchase.access-service.js read/wrote fields that don't exist on the PostPurchase Sequelize model (user_id/post_id/amount/creatorEarningsCoins/purchasedAt, and no sellerId was ever set) instead of the real buyerId/postId/coinPrice/sellerEarningsCoins/sellerId. Since this manager was never called from any resolver, it had never actually run — purchasePost would have thrown a NOT NULL constraint violation on its very first real call. Rewritten field-for-field to match the already-working sibling feature, message-purchase.manager.js, and to use coinTransactionManager.createTransaction (which checks balance and updates UserCoinBalance atomically per call) instead of hand-rolled UserCoinBalance updates.
Data model
PostPurchase (table post_purchase):
| Field | Type | Description |
|---|---|---|
id | UUID | Purchase record ID |
postId | UUID | The exclusive post |
buyerId | UUID | User who purchased access |
sellerId | UUID | Post creator |
coinPrice | Int | Total coins paid |
platformFeeCoins | Int | 10% platform fee (in coins) |
sellerEarningsCoins | Int | Coins credited to the seller (90%) |
coinTransactionId | UUID | The buyer-side debit CoinTransaction row |
status | String | completed | refunded |
refundReason / refundedAt | String / DateTime | Populated if refunded |
createdAt | DateTime | When access was purchased |
GraphQL API
Setting a price on a post
Set coinPrice on createPost/updatePost. Any positive integer makes the post exclusive (isPaid becomes true, computed server-side — it's never accepted directly as client input). Passing 0 or null on update clears the price and un-marks the post as exclusive. On updatePost, lowering coinPrice below the post's current price stores the old price in originalCoinPrice (post.manager.js#updatePost); raising it or clearing it resets originalCoinPrice back to null. Individual items in mediaItems/PostMediaInput can be flagged isPreview: true to stay visible to viewers who haven't purchased.
mutation CreateExclusivePost($input: PostCreateInput!) {
createPost(input: $input) { id isPaid coinPrice }
}
# input: { text: "...", mediaItems: [{ url: "...", isPreview: true }, { url: "..." }], coinPrice: 50 }
Checking access
Post.hasPostAccess is true for the post's owner, for any viewer when the post isn't paid, and for a buyer who purchased it — anonymous viewers never have access to a paid post. When hasPostAccess is false, Post.media resolves to null server-side (or, if the creator marked one or more items as a free preview, to just those isPreview items) — the real media URLs of the still-locked items are never sent to a viewer without access. Post.hiddenMediaCount reports how many media items remain hidden.
query ExclusivePostsGrid($userId: ID!) {
userPosts(userId: $userId) {
id isPaid coinPrice originalCoinPrice hasPostAccess hiddenMediaCount
media { mediaUrl mediaType thumbnailUrl isPreview }
}
}
Purchase access
purchasePostAccess deducts coinPrice from the buyer's wallet, credits 90% to the seller, and immediately grants access. Idempotent — calling it again for an already-purchased post returns the existing purchase record rather than charging again.
mutation PurchasePostAccess($postId: ID!) {
purchasePostAccess(postId: $postId) {
id coinPrice sellerEarningsCoins
post { id hasPostAccess media { mediaUrl } }
}
}
Creator queries
query MyPostPurchaseSales($limit: Int, $offset: Int) {
myPostPurchaseSales(limit: $limit, offset: $offset) {
id coinPrice sellerEarningsCoins createdAt
buyer { id username }
post { id text }
}
}
query PostPurchaseStats($postId: ID!) {
postPurchaseStats(postId: $postId) {
totalPurchases totalRevenue totalPlatformFees totalCreatorEarnings
}
}
query MyPostPurchaseEarnings {
myPostPurchaseEarnings { totalSales totalRevenue totalEarnings platformFees }
}
Buyer history
query MyPurchasedPosts($limit: Int, $offset: Int) {
myPurchasedPosts(limit: $limit, offset: $offset) {
id coinPrice createdAt
post { id text media { mediaUrl } }
}
}
Refund
refundPostPurchase is buyer self-service, gated on context.user (not context.admin) in post-purchase.resolver.js: the caller must be the purchase's original buyer, and the request must fall within a 24h BUYER_REFUND_WINDOW_MS window of PostPurchase.createdAt.
mutation RefundPostPurchase($purchaseId: ID!, $reason: String!) {
refundPostPurchase(purchaseId: $purchaseId, reason: $reason) { id status }
}
Past the buyer's window, or for anyone other than the original buyer, only a moderator/admin can process the refund — via the separate, unbounded admin-schema equivalents adminGetPostPurchases/adminRefundPostPurchase (post-purchase-admin.type.js), gated by the MODERATE_CONTENT permission (or super_admin) instead of a buyer/window check. Both paths delegate to the same postPurchaseManager.getAllPurchases/refundPurchase, and the admin panel's /moderation/refunds page uses this admin path:
query AdminGetPostPurchases($limit: Int, $offset: Int, $status: String) {
adminGetPostPurchases(limit: $limit, offset: $offset, status: $status) { id status coinPrice }
}
mutation AdminRefundPostPurchase($purchaseId: ID!, $reason: String!) {
adminRefundPostPurchase(purchaseId: $purchaseId, reason: $reason) { id status }
}
Fee structure
| Party | Share |
|---|---|
| Seller (creator) | 90% of coin price |
| Platform | 10% of coin price |
Where exclusive posts show up on a profile
Exclusive posts no longer have a dedicated profile tab — they render inline in the normal "posts" grid (PublicProfilePage.tsx), marked with a coin-price badge, and tapping one opens the same PostModal.tsx used for any other post, which renders the lock/unlock UI. The profile's Coins-icon tab (paidChats) now holds only paid group chats (paidGroupChatsByCreator) — see Creator Subscriptions.
Auto-generated blurred preview
At createPost() time (post.manager.js), every locked (non-isPreview) image on a post with coinPrice > 0 gets a heavily blurred + downscaled stand-in image generated server-side and stored as PostMedia.blurredPreviewUrl — see services/post-media-processing.service.js. The Post.media resolver (post.resolver.js) falls back to it whenever a locked viewer has no manually-chosen isPreview item: it swaps that item's mediaUrl/thumbnailUrl to the blurred version instead of hiding the item entirely, so a locked post shows a blurred thumbnail rather than a bare lock icon. The real, unblurred mediaUrl is never sent to a viewer without access.
Creators can still manually choose specific real photos on a paid post as a free preview (isPreview, set from CreatePostModal.tsx) — those take priority over the auto-generated blur, and the media resolver returns them as-is to a locked viewer, with Post.hiddenMediaCount reporting how many items are still hidden.
Toggle: GENERATE_BLURRED_PREVIEWS env var (default on — set to false to disable; purely additive, so disabling it just stops populating blurredPreviewUrl on new posts). Video media is skipped for now (fluent-ffmpeg's video-processing.service.js only operates on file paths/URLs, not in-memory buffers, which the current pipeline uses for images via sharp; wiring a blurred-frame extraction for video would need its own path-based flow) — a locked post whose only media is video still shows the plain lock icon.