Skip to main content

Exclusive Posts (Post Purchases) — Technical Reference

← Back to Exclusive Posts (Post Purchases)

Where this lives

Backend

Frontend

Technical implementation checklist

  • coinPrice post pricing — exposed on PostCreateInput/PostUpdateInput; isPaid is computed server-side from it
  • purchasePostAccess — wired; debits the buyer, credits the seller 90%, creates a PostPurchase row, increments Post.purchaseCount, notifies the seller
  • hasPostAccess — both a standalone hasPostAccess(postId) query and a Post.hasPostAccess field 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 of Settings → Sales & earnings (CreatorSalesPage.tsx). postPurchaseStats (per-post stats) is consumed by PostInsightsPage.tsx
  • myPurchasedPosts / refundPostPurchasemyPurchasedPosts backs the "My purchases" tab of the same CreatorSalesPage.tsx. refundPostPurchase is the buyer-facing self-service refund, gated on context.user. Fixed this session: it previously required context.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 24h BUYER_REFUND_WINDOW_MS of PostPurchase.createdAt, throwing post_purchase.not_owner/post_purchase.refund_window_expired otherwise. The admin panel still uses the separate, unbounded admin-schema equivalents adminGetPostPurchases/adminRefundPostPurchase on /moderation/refunds, gated by MODERATE_CONTENT/super_admin instead 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 — isPreview on PostMediaInput/PostMedia lets a creator mark specific photos of a paid post as free; Post.media returns just those items to a locked viewer instead of null, and Post.hiddenMediaCount reports how many remain hidden
  • Price-drop bookkeeping — lowering coinPrice on an already-paid post stores the prior price as Post.originalCoinPrice (post.manager.js#updatePost) so the client can show it struck-through; raising or clearing the price resets originalCoinPrice to null

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):

FieldTypeDescription
idUUIDPurchase record ID
postIdUUIDThe exclusive post
buyerIdUUIDUser who purchased access
sellerIdUUIDPost creator
coinPriceIntTotal coins paid
platformFeeCoinsInt10% platform fee (in coins)
sellerEarningsCoinsIntCoins credited to the seller (90%)
coinTransactionIdUUIDThe buyer-side debit CoinTransaction row
statusStringcompleted | refunded
refundReason / refundedAtString / DateTimePopulated if refunded
createdAtDateTimeWhen 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

PartyShare
Seller (creator)90% of coin price
Platform10% 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.