Promociones de Publicaciones — Referencia Técnica
← Volver a Promociones de Publicaciones
Este documento fue reescrito sustancialmente dos veces. Primera pasada: la versión original describía un plan previo a la implementación (presupuestos en USD cobrados con Stripe, adminReviewPromotion, myPostPromotions, postPromotionAnalytics con ROI/desglose diario, mutaciones trackPromotionImpression/trackPromotionClick) que no coincidía con lo que realmente se construyó — la implementación real cobra en monedas y usa nombres de operación diferentes. Segunda pasada (esta): el frontend orientado al creador que se describía a continuación como faltante ya ha sido construido.
Dónde vive esto
Backend
apps/backend/graphql/types/post-promotion.type.js— esquema real, conectado a la API de GraphQL en vivo (no administrativa)apps/backend/graphql/resolvers/post-promotion.resolver.js— resolvers para todas las operaciones no administrativas, además de las dos operaciones de administrador (adminApprovePromotion/adminRejectPromotion, restringidas medianteMANAGE_PROMOTIONS)apps/backend/managers/post-managers/post-promotion.manager.js(~635 líneas) — lógica real (no simulada) del ciclo de vida de la campañaapps/backend/data-access-services/post/post-promotion.access-service.js— consultas a la base de datos dePostPromotion- Clasificación del feed:
apps/backend/data-access-services/post/post.access-service.js(getFeed, cerca de la líneaWEIGHTS.promotion) otorga a las promocionesactiveun impulso de peso de0.15en la clasificación del feed principal — este es el único lugar donde una promoción tiene un efecto real y visible en el producto más allá del propio registro de la campaña.
Frontend
- Orientado al creador (
apps/frontend-nextjs): construido en esta pasada.apps/frontend-nextjs/src/components/CreatePromotionModal.tsx— formulario de creación de campaña: nombre, objetivo, tipo de presupuesto, monto del presupuesto (monedas, con una verificación en vivo demyCoinBalancey el mínimo de 50 monedas aplicado del lado del cliente), fechas de inicio/fin. Deliberadamente no tiene campos de segmentación de audiencia, ya que esos datos son inertes en el backend (ver más abajo) — mostrar una interfaz de segmentación implicaría una capacidad que no existe.apps/frontend-nextjs/src/page-components/settings/MyPromotionsPage.tsx(enrutado en/settings/promotions) — lista las campañas mediantemyPromotions, estadísticas por campaña mediantepromotionStats, y acciones de Pausar/Reanudar/Cancelar según el estado.apps/frontend-nextjs/src/components/PostOptionsMenu.tsx— agrega una entrada "Promocionar publicación" / "Ver promoción" restringida al propietario, respaldada por una consultapostPromotion(postId)que está condicionada al estadoopendel menú (no se ejecuta de forma anticipada en cada renderizado de la tarjeta, para evitar una consulta por publicación en una página que lista muchas publicaciones propias).PostCard.tsxsigue renderizando por separado la insignia de solo lectura "Promocionado" (isPromoted, junto a la marca de tiempo) para cualquiera que vea una publicación con una promoción activa.PostModal.tsxno renderiza esta insignia — solo muestra la etiqueta de patrocinador/contenido de marca y el CTA, no el estado de la promoción.
- Cola de revisión de administrador (
apps/frontend-admin): real.apps/frontend-admin/src/app/promotions/page.tsx— una cola funcional de aprobar/rechazar, restringida medianteMANAGE_PROMOTIONS. Ver Revisión de Promociones de Publicaciones.
Checklist de implementación técnica
-
createPostPromotion— lógica real del manager: cobra el presupuesto en monedas de inmediato (mínimo 50 monedas), establece el estadopending_review, exige una sola promoción en curso por publicación (la protección del lado del servidor solo bloquea duplicadospending_review/active, nopaused— el frontend también tratapausedcomo "tiene una promoción" para evitar una experiencia confusa de campaña doble). Ahora conectado aCreatePromotionModal.tsx. - Contenido de marca —
Post.sponsor+PostCreateInput.sponsorUserId/actionButtonText/actionButtonUrl; renderiza la etiqueta de asociación paga + CTA enPostCard.tsx/PostModal.tsx(migración20260721110000-add-sponsor-to-post) -
promoteLiveStream(liveStreamId)— crea o reutiliza una publicación de anuncio para una transmisión en vivo y la ejecuta a través del flujo de promoción -
updatePostPromotion/pausePostPromotion/resumePostPromotion/cancelPostPromotion— transiciones reales de máquina de estados;cancelPostPromotion(y el rechazo del administrador) reembolsan el presupuesto no gastado mediante registros reales decoin-transaction.updatePostPromotionno es utilizado por el nuevo frontend (solo permite editarcampaignName/targetAudience, y únicamente antes de la revisión — aún no es lo suficientemente útil como para construir una interfaz de edición dedicada); pausar/reanudar/cancelar están conectados aMyPromotionsPage.tsx. -
promotionStats— CTR, CPC, CPM ydaysRemainingse calculan a partir de campos reales y se muestran enMyPromotionsPage.tsx. El ROI y un desglose de serie temporal diaria aún no existen en el esquema — nunca se construyeron. -
estimatePromotionReach— ahora es una estimación real derivada del número de seguidores del promotor, el CTR de la interacción de sus publicaciones recientes, y el presupuesto/duración/amplitud de segmentación de la promoción. Una cuenta nueva sin seguidores y sin presupuesto estima ~0 (ya no hay valores fijos de 5000/15000/300). -
targetAudience(JSON de ubicación/demografía/intereses) — se almacena al crear/actualizar, pero nunca se lee ni se aplica en ningún lugar. Puramente inerte — deliberadamente no se expone en el nuevo formulario de creación. -
trackPromotionImpression/trackPromotionClick— los métodostrackImpression/trackClickdel manager ahora se exponen como mutaciones y se llaman realmente:PostCard.tsxdisparatrackPromotionImpressionuna vez por cada publicación patrocinada activa mostrada (protegido mediante una ref para que los re-renderizados no cuenten dos veces) ytrackPromotionClickcuando se toca su CTA. Ambas son operaciones nulas suaves del lado del servidor (devuelvenfalseen lugar de lanzar un error ante cualquier problema, incluida la falta de autenticación).impressions/clicksen el modelo ahora se incrementan en la práctica, alimentando el CTR/CPC/CPM depromotionStats.spentAmounttodavía no es decrementado por nada — aún no existe un modelo de costo en monedas por impresión/clic. -
myPromotions/postPromotion(postId)/promotionById(id)— consultas de lectura reales;myPromotionsypostPromotionahora se consumen (MyPromotionsPage.tsxyPostOptionsMenu.tsx, respectivamente).promotionByIdtodavía no tiene un consumidor directo en el frontend. -
adminApprovePromotion/adminRejectPromotion/adminGetPendingPromotions— reales, y conectados a una página real de revisión de administrador. Aprobar cambia la campaña aactive, que es el único estado que recibe el impulso en la clasificación del feed. Ver Revisión de Promociones de Publicaciones.
Modelo de campaña (tal como está implementado realmente)
| Campo | Descripción |
|---|---|
id / postId | ID de la campaña / la publicación que se está promocionando |
campaignName | Nombre para mostrar |
objective | Objetivo de la campaña: reach, engagement, traffic, conversions o brand_awareness (un simple STRING(50), no un enum de GraphQL) |
budgetType | daily o lifetime |
budgetAmount | Monedas, no USD — se cobra por adelantado al crear (mín. 50) |
spentAmount | Monedas gastadas hasta el momento — todavía no se decrementa por nada (no existe un modelo de costo en monedas por impresión/clic) |
targetAudience | JSON — se almacena, nunca se aplica |
status | pending_review, active, paused, completed, rejected, cancelled (la columna del modelo también tiene por defecto draft, pero createPromotion siempre crea las filas directamente en pending_review — draft nunca se produce en la práctica) |
startsAt / endsAt | Ventana de la campaña |
impressions / clicks | Se incrementan mediante trackPromotionImpression/trackPromotionClick, disparados desde PostCard.tsx — ver más arriba |
cpm / cpc / ctr | Calculados por promotionStats a partir de lo que impressions/clicks/spentAmount contengan en ese momento |
API de GraphQL
Crear y gestionar una campaña
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) }
Leer campañas y estadísticas
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
}
}
Revisión de administrador
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 } }
Ver Revisión de Promociones de Publicaciones para el lado del panel de administración.