Publicaciones Exclusivas (Compras de Publicaciones) — Referencia Técnica
← Volver a Publicaciones Exclusivas (Compras de Publicaciones)
Dónde vive esto
Backend
apps/backend/managers/post-managers/post-purchase.manager.js— lógica de compra, reembolso y verificación de acceso (purchasePost,verifyAccess,refundPurchase,getPurchaseStats,getCreatorEarnings,getCreatorSales)apps/backend/data-access-services/post/post-purchase.access-service.js— acceso a la base de datos de los registros de compraapps/backend/graphql/types/post-purchase.type.js/post-purchase.resolver.js— la capa de GraphQL descrita más abajoapps/backend/graphql/types/post.type.js— camposPost.isPaid/Post.coinPrice/Post.originalCoinPrice/Post.purchaseCount/Post.hasPostAccess/Post.hiddenMediaCount, yPostMedia.isPreview/PostMediaInput.isPreviewapps/backend/validators/post.validator.js— validación decoinPriceal crear/actualizar;isPaidsiempre se calcula en el servidor a partir decoinPrice > 0, nunca se acepta directamente del cliente
Frontend
apps/frontend-nextjs/src/components/CreatePostModal.tsx— interruptor de precio "Hacer exclusivo" junto al selector de visibilidad, además de un interruptor "marcar como vista previa gratuita" en cada miniaturaapps/frontend-nextjs/src/components/PostCard.tsx/PostModal.tsx— la superposición de bloqueo, el botón "Desbloquear por N monedas" + diálogo de confirmación, y la indicación "+N bloqueados" de medios ocultos;purchasePostAccessse llama desde aquíapps/frontend-nextjs/src/page-components/PublicProfilePage.tsx— las publicaciones exclusivas se muestran en línea dentro de la grilla normal del perfil con una insignia de precio en monedas (al abrir una se pasa porPostCard/PostModalmencionados arriba); los chats grupales de pago ahora tienen su propia pestaña separada "paidChats", que ya no se comparte con las publicaciones exclusivas (ver Perfil y Suscripciones de Creador)apps/frontend-nextjs/src/page-components/settings/CreatorSalesPage.tsx—Configuración → Ventas y ganancias: pestaña "Ventas" (myPostPurchaseEarnings+myPostPurchaseSales) y pestaña "Mis compras" (myPurchasedPosts)apps/frontend-nextjs/src/page-components/PostInsightsPage.tsx— estadísticas de compra por publicación (postPurchaseStats) que se muestran junto con vistas/me gusta/comentarios/compartidosapps/frontend-admin/src/app/moderation/refunds/page.tsx— cola de reembolsos del administrador (adminGetPostPurchases/adminRefundPostPurchase)
Checklist de implementación técnica
- Precio en monedas (
coinPrice) de la publicación — expuesto enPostCreateInput/PostUpdateInput;isPaidse calcula en el servidor a partir de él -
purchasePostAccess— conectado; debita al comprador, acredita el 90% al vendedor, crea una filaPostPurchase, incrementaPost.purchaseCount, notifica al vendedor -
hasPostAccess— están conectados tanto una query independientehasPostAccess(postId)como un resolver de campoPost.hasPostAccess(el resolver de campo es el que realmente usan las tarjetas/modales de publicaciones, para evitar una ida y vuelta extra por cada post) -
myPostPurchaseSales/myPostPurchaseEarnings— conectados en el backend y consumidos por la pestaña "Ventas" deConfiguración → Ventas y ganancias(CreatorSalesPage.tsx).postPurchaseStats(estadísticas por publicación) es consumido porPostInsightsPage.tsx -
myPurchasedPosts/refundPostPurchase—myPurchasedPostsrespalda la pestaña "Mis compras" del mismoCreatorSalesPage.tsx.refundPostPurchasees el reembolso de autoservicio para el comprador, protegido concontext.user. Corregido en esta sesión: antes requeríacontext.admin, que el esquema del cliente nunca completa, dejándola permanentemente inalcanzable desde cualquier solicitud real del cliente; ahora verifica que quien llama sea el comprador real de la compra (purchase.buyerId === user.userId) y que la solicitud esté dentro de una ventana de 24h (BUYER_REFUND_WINDOW_MS) desdePostPurchase.createdAt, lanzandopost_purchase.not_owner/post_purchase.refund_window_expireden caso contrario. El panel de administración sigue usando los equivalentes del esquema de administración, separados y sin límite de tiempo,adminGetPostPurchases/adminRefundPostPurchaseen/moderation/refunds, protegidos porMODERATE_CONTENT/super_adminen lugar de una verificación de comprador/ventana - Interfaz de pago/desbloqueo en el frontend — las publicaciones exclusivas se muestran en línea dentro de la grilla normal del feed/perfil con una insignia de precio en monedas, no en una pestaña dedicada del perfil. Al abrir una publicación bloqueada aparece una superposición de bloqueo, un botón "Desbloquear por N monedas" con un paso de confirmación, y una indicación "+N bloqueados" de medios ocultos, todo dentro de
PostCard.tsx/PostModal.tsx - Vistas previas gratuitas por foto —
isPreviewenPostMediaInput/PostMediapermite que un creador marque fotos específicas de una publicación de pago como gratuitas;Post.mediadevuelve solo esos elementos a un visitante bloqueado en lugar denull, yPost.hiddenMediaCountindica cuántos permanecen ocultos - Registro de bajadas de precio — bajar
coinPriceen una publicación ya de pago guarda el precio anterior comoPost.originalCoinPrice(post.manager.js#updatePost) para que el cliente pueda mostrarlo tachado; subir o borrar el precio restableceoriginalCoinPriceanull
Un bug real encontrado y corregido al conectar esto
Antes de este trabajo, cada método de post-purchase.manager.js y post-purchase.access-service.js leía/escribía campos que no existen en el modelo Sequelize PostPurchase (user_id/post_id/amount/creatorEarningsCoins/purchasedAt, y nunca se asignaba sellerId) en lugar de los reales buyerId/postId/coinPrice/sellerEarningsCoins/sellerId. Como este manager nunca se llamaba desde ningún resolver, nunca se había ejecutado realmente — purchasePost habría lanzado una violación de restricción NOT NULL en su primera llamada real. Se reescribió campo por campo para igualar la función hermana ya funcional, message-purchase.manager.js, y para usar coinTransactionManager.createTransaction (que verifica el saldo y actualiza UserCoinBalance de forma atómica en cada llamada) en lugar de actualizaciones manuales de UserCoinBalance.
Modelo de datos
PostPurchase (tabla post_purchase):
| Campo | Tipo | Descripción |
|---|---|---|
id | UUID | ID del registro de compra |
postId | UUID | La publicación exclusiva |
buyerId | UUID | Usuario que compró el acceso |
sellerId | UUID | Creador de la publicación |
coinPrice | Int | Total de monedas pagadas |
platformFeeCoins | Int | Comisión de la plataforma del 10% (en monedas) |
sellerEarningsCoins | Int | Monedas acreditadas al vendedor (90%) |
coinTransactionId | UUID | La fila CoinTransaction del débito del comprador |
status | String | completed | refunded |
refundReason / refundedAt | String / DateTime | Se completan si se reembolsa |
createdAt | DateTime | Cuándo se compró el acceso |
API de GraphQL
Establecer un precio en una publicación
Establece coinPrice en createPost/updatePost. Cualquier entero positivo hace exclusiva la publicación (isPaid se vuelve true, calculado en el servidor — nunca se acepta directamente como entrada del cliente). Pasar 0 o null al actualizar borra el precio y quita la marca de exclusiva. En updatePost, bajar coinPrice por debajo del precio actual de la publicación guarda el precio anterior en originalCoinPrice (post.manager.js#updatePost); subirlo o borrarlo restablece originalCoinPrice a null. Los elementos individuales de mediaItems/PostMediaInput pueden marcarse con isPreview: true para permanecer visibles a los visitantes que no hayan comprado.
mutation CreateExclusivePost($input: PostCreateInput!) {
createPost(input: $input) { id isPaid coinPrice }
}
# input: { text: "...", mediaItems: [{ url: "...", isPreview: true }, { url: "..." }], coinPrice: 50 }
Verificar acceso
Post.hasPostAccess es true para el dueño de la publicación, para cualquier visitante cuando la publicación no es de pago, y para un comprador que la haya adquirido — los visitantes anónimos nunca tienen acceso a una publicación de pago. Cuando hasPostAccess es false, Post.media resuelve a null en el servidor (o, si el creador marcó uno o más elementos como vista previa gratuita, a solo esos elementos isPreview) — las URLs reales de los medios que siguen bloqueados nunca se envían a un visitante sin acceso. Post.hiddenMediaCount indica cuántos elementos de medios permanecen ocultos.
query ExclusivePostsGrid($userId: ID!) {
userPosts(userId: $userId) {
id isPaid coinPrice originalCoinPrice hasPostAccess hiddenMediaCount
media { mediaUrl mediaType thumbnailUrl isPreview }
}
}
Comprar acceso
purchasePostAccess descuenta coinPrice de la billetera del comprador, acredita el 90% al vendedor y otorga el acceso de inmediato. Es idempotente — llamarla de nuevo para una publicación ya comprada devuelve el registro de compra existente en lugar de cobrar otra vez.
mutation PurchasePostAccess($postId: ID!) {
purchasePostAccess(postId: $postId) {
id coinPrice sellerEarningsCoins
post { id hasPostAccess media { mediaUrl } }
}
}
Consultas del creador
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 }
}
Historial del comprador
query MyPurchasedPosts($limit: Int, $offset: Int) {
myPurchasedPosts(limit: $limit, offset: $offset) {
id coinPrice createdAt
post { id text media { mediaUrl } }
}
}
Reembolso
refundPostPurchase es de autoservicio para el comprador, protegido con context.user (no context.admin) en post-purchase.resolver.js: quien llama debe ser el comprador original de la compra, y la solicitud debe estar dentro de una ventana de 24h (BUYER_REFUND_WINDOW_MS) desde PostPurchase.createdAt.
mutation RefundPostPurchase($purchaseId: ID!, $reason: String!) {
refundPostPurchase(purchaseId: $purchaseId, reason: $reason) { id status }
}
Fuera de la ventana del comprador, o para cualquiera que no sea el comprador original, solo un moderador/administrador puede procesar el reembolso — a través de los equivalentes del esquema de administración, separados y sin límite de tiempo, adminGetPostPurchases/adminRefundPostPurchase (post-purchase-admin.type.js), protegidos por el permiso MODERATE_CONTENT (o super_admin) en lugar de una verificación de comprador/ventana. Ambos caminos delegan en los mismos postPurchaseManager.getAllPurchases/refundPurchase, y la página /moderation/refunds del panel de administración usa este camino de administrador:
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 }
}
Estructura de comisiones
| Parte | Porcentaje |
|---|---|
| Vendedor (creador) | 90% del precio en monedas |
| Plataforma | 10% del precio en monedas |
Dónde aparecen las publicaciones exclusivas en un perfil
Las publicaciones exclusivas ya no tienen una pestaña dedicada en el perfil — se muestran en línea dentro de la grilla normal de "publicaciones" (PublicProfilePage.tsx), marcadas con una insignia de precio en monedas, y al tocar una se abre el mismo PostModal.tsx que se usa para cualquier otra publicación, el cual muestra la interfaz de bloqueo/desbloqueo. La pestaña del ícono de monedas del perfil (paidChats) ahora contiene solo los chats grupales de pago (paidGroupChatsByCreator) — ver Suscripciones de Creador.
Vista previa borrosa autogenerada
Al momento de createPost() (post.manager.js), cada foto imagen bloqueada (no marcada como isPreview) de una publicación con coinPrice > 0 obtiene una imagen sustituta muy borrosa y reducida de tamaño, generada en el servidor y almacenada como PostMedia.blurredPreviewUrl — ver services/post-media-processing.service.js. El resolver Post.media (post.resolver.js) recurre a ella cuando un visitante bloqueado no tiene ningún elemento isPreview elegido manualmente: intercambia el mediaUrl/thumbnailUrl de ese elemento por la versión borrosa en lugar de ocultarlo por completo, de modo que una publicación bloqueada muestra una miniatura borrosa en vez de un simple ícono de candado. El mediaUrl real, sin difuminar, nunca se envía a un visitante sin acceso.
Los creadores aún pueden elegir manualmente fotos reales específicas de una publicación de pago como vista previa gratuita (isPreview, definido desde CreatePostModal.tsx) — estas tienen prioridad sobre el difuminado autogenerado, y el resolver de medios las devuelve tal cual a un visitante bloqueado, con Post.hiddenMediaCount indicando cuántos elementos siguen ocultos.
Interruptor: variable de entorno GENERATE_BLURRED_PREVIEWS (activada por defecto — configúrala en false para desactivarla; es puramente aditiva, así que desactivarla solo detiene la generación de blurredPreviewUrl en publicaciones nuevas). El video se omite por ahora (el video-processing.service.js basado en fluent-ffmpeg solo opera sobre rutas de archivo/URLs, no sobre buffers en memoria, que es lo que usa el pipeline actual para imágenes vía sharp; conectar una extracción de fotograma borroso para video necesitaría su propio flujo basado en rutas de archivo) — una publicación bloqueada cuyo único medio sea video sigue mostrando el simple ícono de candado.