Back to 0fee
0fee

El modulo de solicitud de funcionalidades: ciclo de retroalimentacion para desarrolladores

Como construimos el sistema de solicitud de funcionalidades de 0fee.dev con 4 tablas, 20 rutas API, votacion y niveles de prioridad. Por Juste A. Gnimavo.

Thales & Claude | March 30, 2026 10 min 0fee
EN/ FR/ ES
feature-requestsdeveloper-experiencefeedbackapi-design

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:

EstadoSignificadoQuien lo establece
openRecien enviada, pendiente de revisionSistema (predeterminado)
plannedAceptada y programada para desarrolloAdmin
in-progressActualmente en construccionAdmin
completedEnviada y disponibleAdmin
backlogReconocida pero no priorizadaAdmin
duplicateFusionada con otra solicitudAdmin
declinedNo planificada, con explicacionAdmin

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 request

duplicate 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")  # Desuscribirse

Rutas 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")   # Recategorizar

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

PrioridadSignificadoObjetivo de tiempo de respuesta
P0Critico -- bloqueando uso en produccionEl mismo dia
P1Alto -- impacto significativo en desarrolladoresEsta semana
P2Medio -- mejora notablemente la experienciaEste mes
P3Bajo -- bueno tenerlo, sin urgenciaCuando 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 request

Las 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.

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles