Saltar al contenido principal

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

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 de myCoinBalance y 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 mediante myPromotions, estadísticas por campaña mediante promotionStats, 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 consulta postPromotion(postId) que está condicionada al estado open del 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.tsx sigue 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.tsx no 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 mediante MANAGE_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 estado pending_review, exige una sola promoción en curso por publicación (la protección del lado del servidor solo bloquea duplicados pending_review/active, no paused — el frontend también trata paused como "tiene una promoción" para evitar una experiencia confusa de campaña doble). Ahora conectado a CreatePromotionModal.tsx.
  • Contenido de marca — Post.sponsor + PostCreateInput.sponsorUserId / actionButtonText / actionButtonUrl; renderiza la etiqueta de asociación paga + CTA en PostCard.tsx / PostModal.tsx (migración 20260721110000-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 de coin-transaction. updatePostPromotion no es utilizado por el nuevo frontend (solo permite editar campaignName/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 a MyPromotionsPage.tsx.
  • promotionStats — CTR, CPC, CPM y daysRemaining se calculan a partir de campos reales y se muestran en MyPromotionsPage.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étodos trackImpression/trackClick del manager ahora se exponen como mutaciones y se llaman realmente: PostCard.tsx dispara trackPromotionImpression una vez por cada publicación patrocinada activa mostrada (protegido mediante una ref para que los re-renderizados no cuenten dos veces) y trackPromotionClick cuando se toca su CTA. Ambas son operaciones nulas suaves del lado del servidor (devuelven false en lugar de lanzar un error ante cualquier problema, incluida la falta de autenticación). impressions/clicks en el modelo ahora se incrementan en la práctica, alimentando el CTR/CPC/CPM de promotionStats. spentAmount todaví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; myPromotions y postPromotion ahora se consumen (MyPromotionsPage.tsx y PostOptionsMenu.tsx, respectivamente). promotionById todaví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 a active, 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)

CampoDescripción
id / postIdID de la campaña / la publicación que se está promocionando
campaignNameNombre para mostrar
objectiveObjetivo de la campaña: reach, engagement, traffic, conversions o brand_awareness (un simple STRING(50), no un enum de GraphQL)
budgetTypedaily o lifetime
budgetAmountMonedas, no USD — se cobra por adelantado al crear (mín. 50)
spentAmountMonedas gastadas hasta el momento — todavía no se decrementa por nada (no existe un modelo de costo en monedas por impresión/clic)
targetAudienceJSON — se almacena, nunca se aplica
statuspending_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_reviewdraft nunca se produce en la práctica)
startsAt / endsAtVentana de la campaña
impressions / clicksSe incrementan mediante trackPromotionImpression/trackPromotionClick, disparados desde PostCard.tsx — ver más arriba
cpm / cpc / ctrCalculados 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.