Una plataforma de pagos sin un mecanismo de retroalimentacion es una plataforma que construye a ciegas. Puedes adivinar lo que quieren los desarrolladores. Puedes leer informes de la industria. Pero hasta que les des a los desarrolladores una forma de decirte "necesito X" y votar las solicitudes de los demas, estas tomando decisiones de producto en el vacio.
En la sesion 035, construimos un modulo completo de solicitud de funcionalidades para 0fee.dev. Cuatro tablas de base de datos, siete estados, cuatro categorias, veinte rutas API, votacion positiva, comentarios, suscripciones, fusion de duplicados y niveles de prioridad. Tomo una sesion construir lo que la mayoria de los equipos estimarian como un proyecto de multiples sprints.
El modelo de datos: cuatro tablas
El sistema de solicitud de funcionalidades esta respaldado por cuatro tablas que capturan el ciclo de vida completo de una solicitud desde su envio hasta su resolucion:
python# models/feature_request.py
class FeatureRequest(Base):
__tablename__ = "feature_requests"
id = Column(String, primary_key=True, default=generate_id)
user_id = Column(String, ForeignKey("users.id"), nullable=False)
title = Column(String(200), nullable=False)
description = Column(Text, nullable=False)
category = Column(String(50), nullable=False) # bug, feature-request, documentation, performance
status = Column(String(50), default="open")
priority = Column(String(10), nullable=True) # P0, P1, P2, P3
merged_into_id = Column(String, ForeignKey("feature_requests.id"), nullable=True)
admin_response = Column(Text, nullable=True)
upvote_count = Column(Integer, default=0) # Desnormalizado para ordenamiento rapido
comment_count = Column(Integer, default=0)
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, onupdate=func.now())
resolved_at = Column(DateTime, nullable=True)
# Relaciones
user = relationship("User", back_populates="feature_requests")
upvotes = relationship("FeatureUpvote", back_populates="request", cascade="all, delete-orphan")
comments = relationship("FeatureComment", back_populates="request", cascade="all, delete-orphan")
subscribers = relationship("FeatureSubscriber", back_populates="request", cascade="all, delete-orphan")
class FeatureUpvote(Base): __tablename__ = "feature_upvotes" BLANK id = Column(String, primary_key=True, default=generate_id) request_id = Column(String, ForeignKey("feature_requests.id"), nullable=False) user_id = Column(String, ForeignKey("users.id"), nullable=False) created_at = Column(DateTime, server_default=func.now()) BLANK # Restriccion unica: un voto por usuario por solicitud __table_args__ = (UniqueConstraint("request_id", "user_id"),) BLANK
class FeatureComment(Base): __tablename__ = "feature_comments" BLANK id = Column(String, primary_key=True, default=generate_id) request_id = Column(String, ForeignKey("feature_requests.id"), nullable=False) user_id = Column(String, ForeignKey("users.id"), nullable=False) body = Column(Text, nullable=False) is_admin = Column(Boolean, default=False) created_at = Column(DateTime, server_default=func.now()) updated_at = Column(DateTime, onupdate=func.now()) BLANK
class FeatureSubscriber(Base): __tablename__ = "feature_subscribers" BLANK id = Column(String, primary_key=True, default=generate_id) request_id = Column(String, ForeignKey("feature_requests.id"), nullable=False) user_id = Column(String, ForeignKey("users.id"), nullable=False) created_at = Column(DateTime, server_default=func.now()) BLANK __table_args__ = (UniqueConstraint("request_id", "user_id"),) ```
Los contadores desnormalizados upvote_count y comment_count en la tabla principal merecen explicacion. Podriamos calcularlos con un JOIN o subconsulta en cada solicitud de listado, pero las listas de solicitudes de funcionalidades se ordenan por conteo de votos. La desnormalizacion significa que podemos hacer ORDER BY upvote_count DESC sin tocar la tabla de votos en cada consulta.
Siete estados
Las solicitudes de funcionalidades avanzan a traves de un ciclo de vida definido:
| Estado | Significado | Quien lo establece |
|---|---|---|
open | Recien enviada, pendiente de revision | Sistema (predeterminado) |
planned | Aceptada y programada para desarrollo | Admin |
in-progress | Actualmente en construccion | Admin |
completed | Enviada y disponible | Admin |
backlog | Reconocida pero no priorizada | Admin |
duplicate | Fusionada con otra solicitud | Admin |
declined | No planificada, con explicacion | Admin |
Las transiciones de estado son validadas:
python# services/feature_request.py
VALID_TRANSITIONS = {
"open": ["planned", "in-progress", "backlog", "duplicate", "declined"],
"planned": ["in-progress", "backlog", "declined"],
"in-progress": ["completed", "planned", "backlog"],
"backlog": ["planned", "in-progress", "declined"],
"duplicate": [], # Estado terminal
"completed": [], # Estado terminal
"declined": ["open"], # Se puede reabrir
}
async def update_request_status(
request_id: str,
new_status: str,
admin_response: str = None,
) -> FeatureRequest:
request = await get_request_or_404(request_id)
valid_next = VALID_TRANSITIONS.get(request.status, [])
if new_status not in valid_next:
raise HTTPException(
status_code=400,
detail=f"Cannot transition from '{request.status}' to '{new_status}'. "
f"Valid transitions: {valid_next}"
)
request.status = new_status
if admin_response:
request.admin_response = admin_response
if new_status == "completed":
request.resolved_at = datetime.utcnow()
await db.commit()
# Notificar a los suscriptores
await notify_subscribers(request, new_status)
return requestduplicate y completed son estados terminales -- una vez que una solicitud se marca como enviada o fusionada, no puede transicionar mas. declined se puede reabrir a open, porque a veces una solicitud prematura se vuelve relevante despues.
Cuatro categorias
Toda solicitud de funcionalidad debe ser categorizada al momento del envio:
pythonCATEGORIES = {
"bug": "Something is broken or not working as expected",
"feature-request": "A new capability or enhancement",
"documentation": "Missing, incorrect, or unclear documentation",
"performance": "Speed, latency, or resource usage issues",
}
class FeatureRequestCreate(BaseModel):
title: str = Field(min_length=10, max_length=200)
description: str = Field(min_length=30)
category: str = Field(pattern="^(bug|feature-request|documentation|performance)$")Consideramos mas categorias (seguridad, integracion, interfaz) pero decidimos que cuatro cubren la gran mayoria de las solicitudes. Un desarrollador que reporta un problema de seguridad deberia usar la categoria de bug con una etiqueta "security". Mantener las categorias reducidas previene la paralisis de decision durante el envio.
Las 20 rutas API
El sistema de solicitud de funcionalidades expone 20 rutas, divididas entre endpoints publicos (usuario autenticado) y de administrador:
Rutas publicas (12)
python# Rutas publicas de solicitud de funcionalidades
@router.post("/feature-requests") # Crear una solicitud
@router.get("/feature-requests") # Listar todas (con filtros)
@router.get("/feature-requests/{id}") # Obtener solicitud individual
@router.patch("/feature-requests/{id}") # Editar solicitud propia (si esta abierta)
@router.delete("/feature-requests/{id}") # Eliminar solicitud propia (si esta abierta)
# Votacion
@router.post("/feature-requests/{id}/upvote") # Votar por una solicitud
@router.delete("/feature-requests/{id}/upvote") # Retirar voto
# Comentarios
@router.get("/feature-requests/{id}/comments") # Listar comentarios
@router.post("/feature-requests/{id}/comments") # Agregar comentario
@router.patch("/feature-requests/{id}/comments/{cid}") # Editar comentario propio
@router.delete("/feature-requests/{id}/comments/{cid}") # Eliminar comentario propio
# Suscripcion
@router.post("/feature-requests/{id}/subscribe") # Suscribirse a actualizaciones
@router.delete("/feature-requests/{id}/subscribe") # DesuscribirseRutas de administrador (8)
python# Rutas de administrador para solicitudes de funcionalidades
@router.patch("/admin/feature-requests/{id}/status") # Cambiar estado
@router.patch("/admin/feature-requests/{id}/priority") # Establecer prioridad
@router.post("/admin/feature-requests/{id}/merge") # Fusionar duplicados
@router.post("/admin/feature-requests/{id}/respond") # Respuesta oficial
@router.get("/admin/feature-requests/stats") # Estadisticas agregadas
@router.get("/admin/feature-requests/by-category") # Desglose por categoria
@router.get("/admin/feature-requests/by-status") # Desglose por estado
@router.patch("/admin/feature-requests/{id}/category") # RecategorizarLa separacion importa. Las rutas publicas permiten a los desarrolladores interactuar con el sistema. Las rutas de administrador nos permiten gestionar y clasificar.
Listado con filtros y ordenamiento
El endpoint de listado soporta filtrado completo:
python@router.get("/feature-requests")
async def list_feature_requests(
page: int = Query(1, ge=1),
per_page: int = Query(20, ge=1, le=100),
category: str = Query(None),
status: str = Query(None),
sort: str = Query("newest"), # newest, oldest, most-voted, most-commented
search: str = Query(None),
my_requests: bool = Query(False),
current_user: User = Depends(get_current_user),
):
query = select(FeatureRequest).where(
FeatureRequest.merged_into_id.is_(None) # Excluir solicitudes fusionadas
)
if category:
query = query.where(FeatureRequest.category == category)
if status:
query = query.where(FeatureRequest.status == status)
if my_requests:
query = query.where(FeatureRequest.user_id == current_user.id)
if search:
query = query.where(
or_(
FeatureRequest.title.ilike(f"%{search}%"),
FeatureRequest.description.ilike(f"%{search}%"),
)
)
# Ordenamiento
sort_map = {
"newest": FeatureRequest.created_at.desc(),
"oldest": FeatureRequest.created_at.asc(),
"most-voted": FeatureRequest.upvote_count.desc(),
"most-commented": FeatureRequest.comment_count.desc(),
}
query = query.order_by(sort_map.get(sort, FeatureRequest.created_at.desc()))
# Paginacion
total = await db.scalar(select(func.count()).select_from(query.subquery()))
results = await db.scalars(
query.offset((page - 1) * per_page).limit(per_page)
)
return {
"items": results.all(),
"total": total,
"page": page,
"per_page": per_page,
"pages": ceil(total / per_page),
}Las solicitudes fusionadas se excluyen del listado predeterminado. Si la solicitud #42 fue fusionada con la solicitud #17, solo aparece la #17. El conteo de votos del objetivo de fusion incluye los votos de los duplicados fusionados.
Fusion de duplicados
Una de las operaciones de administrador mas utiles es fusionar solicitudes duplicadas. Cuando dos desarrolladores solicitan independientemente la misma funcionalidad, las fusionamos:
python@router.post("/admin/feature-requests/{id}/merge")
async def merge_request(
id: str,
data: MergeRequest, # { "target_id": "..." }
admin: User = Depends(require_admin_role),
):
source = await get_request_or_404(id)
target = await get_request_or_404(data.target_id)
if source.id == target.id:
raise HTTPException(400, "Cannot merge a request into itself")
if source.status == "duplicate":
raise HTTPException(400, "Request is already merged")
if target.status == "duplicate":
raise HTTPException(400, "Cannot merge into an already-merged request")
# Transferir votos (omitir duplicados)
existing_voters = {u.user_id for u in target.upvotes}
for upvote in source.upvotes:
if upvote.user_id not in existing_voters:
new_upvote = FeatureUpvote(
request_id=target.id,
user_id=upvote.user_id,
)
db.add(new_upvote)
# Transferir suscriptores
existing_subs = {s.user_id for s in target.subscribers}
for sub in source.subscribers:
if sub.user_id not in existing_subs:
new_sub = FeatureSubscriber(
request_id=target.id,
user_id=sub.user_id,
)
db.add(new_sub)
# Marcar origen como duplicado
source.status = "duplicate"
source.merged_into_id = target.id
# Actualizar contadores del objetivo
target.upvote_count = len(target.upvotes) + len(
[u for u in source.upvotes if u.user_id not in existing_voters]
)
await db.commit()
# Notificar a ambos conjuntos de suscriptores
await notify_merge(source, target)
return {"merged": source.id, "into": target.id}La fusion transfiere votos y suscriptores del origen al objetivo, omitiendo usuarios que ya votaron o se suscribieron al objetivo. Esto asegura que la solicitud fusionada refleje con precision el interes total de la comunidad.
Niveles de prioridad: P0 a P3
Las prioridades las establecen los administradores durante la clasificacion:
| Prioridad | Significado | Objetivo de tiempo de respuesta |
|---|---|---|
| P0 | Critico -- bloqueando uso en produccion | El mismo dia |
| P1 | Alto -- impacto significativo en desarrolladores | Esta semana |
| P2 | Medio -- mejora notablemente la experiencia | Este mes |
| P3 | Bajo -- bueno tenerlo, sin urgencia | Cuando este disponible |
python@router.patch("/admin/feature-requests/{id}/priority")
async def set_priority(
id: str,
data: PriorityUpdate, # { "priority": "P1" }
admin: User = Depends(require_admin_role),
):
request = await get_request_or_404(id)
if data.priority not in ("P0", "P1", "P2", "P3"):
raise HTTPException(400, "Priority must be P0, P1, P2, or P3")
request.priority = data.priority
await db.commit()
if data.priority == "P0":
# P0 dispara notificacion inmediata a todos los administradores
await notify_admins_p0(request)
return requestLas solicitudes P0 disparan notificacion inmediata porque representan problemas bloqueantes. Si un desarrollador reporta que los desembolsos estan fallando para un proveedor especifico, eso es un P0 -- y necesita atencion en horas, no en dias.
Notificaciones a suscriptores
Cuando una solicitud cambia de estado, todos los suscriptores reciben una notificacion. El creador de la solicitud se suscribe automaticamente. Cualquiera que vote o comente tambien se suscribe automaticamente (aunque pueden desuscribirse):
python# services/notifications.py
async def notify_subscribers(request: FeatureRequest, new_status: str):
subscribers = await db.scalars(
select(FeatureSubscriber).where(
FeatureSubscriber.request_id == request.id
)
)
status_messages = {
"planned": f"'{request.title}' has been planned for development.",
"in-progress": f"'{request.title}' is now being built.",
"completed": f"'{request.title}' has been shipped!",
"declined": f"'{request.title}' has been declined. See the admin response for details.",
}
message = status_messages.get(new_status)
if not message:
return
for sub in subscribers.all():
await create_notification(
user_id=sub.user_id,
type="feature_request_update",
message=message,
link=f"/feature-requests/{request.id}",
)Lo que aprendimos
Los contadores desnormalizados son esenciales para el ordenamiento. Contar votos via JOIN en cada solicitud de listado seria inaceptable a escala. El upvote_count desnormalizado hace que ordenar por "mas votados" sea un simple ORDER BY.
La fusion de duplicados es la funcionalidad de administrador mas valiosa. Los desarrolladores describen la misma necesidad de diferentes maneras. Sin fusion, obtienes una vista fragmentada de lo que la comunidad realmente quiere. Con fusion, las funcionalidades mas solicitadas naturalmente suben a la cima.
Cuatro categorias son suficientes. Resistimos el impulso de agregar mas. Cada categoria adicional es una decision que el remitente debe tomar, y demasiadas opciones llevan a una categorizacion incorrecta. Error, solicitud de funcionalidad, documentacion, rendimiento -- esto cubre todo.
La suscripcion automatica por interaccion es el valor predeterminado correcto. Si alguien se preocupa lo suficiente para votar o comentar, quiere saber cuando algo cambia. Desuscripcion voluntaria es mejor que suscripcion voluntaria para funcionalidades de participacion.
El modulo de solicitud de funcionalidades se construyo en una sola sesion pero proporciona valor continuo. Es la forma principal en que entendemos lo que los desarrolladores de 0fee.dev necesitan, y alimenta directamente la hoja de ruta del producto.
Este articulo es parte de la serie "Como construimos 0fee.dev". 0fee.dev es un orquestador de pagos que cubre mas de 53 proveedores en mas de 200 paises, construido por Juste A. GNIMAVO y Claude desde Abiyan sin ningun ingeniero humano. Sigue la serie para conocer la historia completa de construccion.