Saltar al contenido principal

Publicaciones Exclusivas (Compras de Publicaciones) — Referencia Técnica

← Volver a Publicaciones Exclusivas (Compras de Publicaciones)

Dónde vive esto

Backend

Frontend

Checklist de implementación técnica

  • Precio en monedas (coinPrice) de la publicación — expuesto en PostCreateInput/PostUpdateInput; isPaid se calcula en el servidor a partir de él
  • purchasePostAccess — conectado; debita al comprador, acredita el 90% al vendedor, crea una fila PostPurchase, incrementa Post.purchaseCount, notifica al vendedor
  • hasPostAccess — están conectados tanto una query independiente hasPostAccess(postId) como un resolver de campo Post.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" de Configuración → Ventas y ganancias (CreatorSalesPage.tsx). postPurchaseStats (estadísticas por publicación) es consumido por PostInsightsPage.tsx
  • myPurchasedPosts / refundPostPurchasemyPurchasedPosts respalda la pestaña "Mis compras" del mismo CreatorSalesPage.tsx. refundPostPurchase es el reembolso de autoservicio para el comprador, protegido con context.user. Corregido en esta sesión: antes requería context.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) desde PostPurchase.createdAt, lanzando post_purchase.not_owner/post_purchase.refund_window_expired en caso contrario. El panel de administración sigue usando los equivalentes del esquema de administración, separados y sin límite de tiempo, adminGetPostPurchases/adminRefundPostPurchase en /moderation/refunds, protegidos por MODERATE_CONTENT/super_admin en 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 — isPreview en PostMediaInput/PostMedia permite que un creador marque fotos específicas de una publicación de pago como gratuitas; Post.media devuelve solo esos elementos a un visitante bloqueado en lugar de null, y Post.hiddenMediaCount indica cuántos permanecen ocultos
  • Registro de bajadas de precio — bajar coinPrice en una publicación ya de pago guarda el precio anterior como Post.originalCoinPrice (post.manager.js#updatePost) para que el cliente pueda mostrarlo tachado; subir o borrar el precio restablece originalCoinPrice a null

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

CampoTipoDescripción
idUUIDID del registro de compra
postIdUUIDLa publicación exclusiva
buyerIdUUIDUsuario que compró el acceso
sellerIdUUIDCreador de la publicación
coinPriceIntTotal de monedas pagadas
platformFeeCoinsIntComisión de la plataforma del 10% (en monedas)
sellerEarningsCoinsIntMonedas acreditadas al vendedor (90%)
coinTransactionIdUUIDLa fila CoinTransaction del débito del comprador
statusStringcompleted | refunded
refundReason / refundedAtString / DateTimeSe completan si se reembolsa
createdAtDateTimeCuá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

PartePorcentaje
Vendedor (creador)90% del precio en monedas
Plataforma10% 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.