Back to 0fee
0fee

The Feature Request Module: Developer Feedback Loop

How we built the 0fee.dev feature request system with 4 tables, 20 API routes, upvoting, and priority levels. By Juste A. Gnimavo.

Thales & Claude | March 25, 2026 9 min 0fee
feature-requestsdeveloper-experiencefeedbackapi-design

A payment platform without a feedback mechanism is a platform building blind. You can guess what developers want. You can read industry reports. But until you give developers a way to tell you "I need X" and vote on each other's requests, you are making product decisions in a vacuum.

In Session 035, we built a complete feature request module for 0fee.dev. Four database tables, seven statuses, four categories, twenty API routes, upvoting, commenting, subscribing, duplicate merging, and priority levels. It took one session to build what most teams would scope as a multi-sprint project.

The Data Model: Four Tables

The feature request system is backed by four tables that capture the complete lifecycle of a request from submission to resolution:

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)  # Denormalized for fast sorting
    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)

    # Relationships
    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 # Unique constraint: one upvote per user per request __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"),) ```

The denormalized upvote_count and comment_count on the main table deserve explanation. We could compute these with a JOIN or subquery on every list request, but feature request lists are sorted by vote count. Denormalizing means we can ORDER BY upvote_count DESC without touching the upvotes table on every query.

Seven Statuses

Feature requests move through a defined lifecycle:

StatusMeaningWho Sets It
openNewly submitted, awaiting reviewSystem (default)
plannedAccepted and scheduled for developmentAdmin
in-progressCurrently being builtAdmin
completedShipped and availableAdmin
backlogAcknowledged but not prioritizedAdmin
duplicateMerged into another requestAdmin
declinedNot planned, with explanationAdmin

The state transitions are validated:

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": [],  # Terminal state
    "completed": [],  # Terminal state
    "declined": ["open"],  # Can be reopened
}

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

    # Notify subscribers
    await notify_subscribers(request, new_status)

    return request

duplicate and completed are terminal states -- once a request is marked as shipped or merged, it cannot transition further. declined can be reopened back to open, because sometimes a request that was premature becomes relevant later.

Four Categories

Every feature request must be categorized on submission:

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)$")

We considered more categories (security, integration, ui) but decided that four covers the vast majority of requests. A developer reporting a security issue should use the bug category with a "security" tag. Keeping categories narrow prevents decision paralysis during submission.

The 20 API Routes

The feature request system exposes 20 routes, split between public (authenticated user) and admin endpoints:

Public Routes (12)

python# Public feature request routes
@router.post("/feature-requests")           # Create a request
@router.get("/feature-requests")            # List all (with filters)
@router.get("/feature-requests/{id}")       # Get single request
@router.patch("/feature-requests/{id}")     # Edit own request (if open)
@router.delete("/feature-requests/{id}")    # Delete own request (if open)

# Upvoting
@router.post("/feature-requests/{id}/upvote")    # Upvote a request
@router.delete("/feature-requests/{id}/upvote")  # Remove upvote

# Comments
@router.get("/feature-requests/{id}/comments")     # List comments
@router.post("/feature-requests/{id}/comments")    # Add comment
@router.patch("/feature-requests/{id}/comments/{cid}")  # Edit own comment
@router.delete("/feature-requests/{id}/comments/{cid}") # Delete own comment

# Subscribing
@router.post("/feature-requests/{id}/subscribe")    # Subscribe to updates
@router.delete("/feature-requests/{id}/subscribe")  # Unsubscribe

Admin Routes (8)

python# Admin feature request routes
@router.patch("/admin/feature-requests/{id}/status")     # Change status
@router.patch("/admin/feature-requests/{id}/priority")   # Set priority
@router.post("/admin/feature-requests/{id}/merge")       # Merge duplicate
@router.post("/admin/feature-requests/{id}/respond")     # Official response
@router.get("/admin/feature-requests/stats")             # Aggregate stats
@router.get("/admin/feature-requests/by-category")       # Breakdown by category
@router.get("/admin/feature-requests/by-status")         # Breakdown by status
@router.patch("/admin/feature-requests/{id}/category")   # Recategorize

The separation matters. Public routes let developers interact with the system. Admin routes let us manage and triage.

Listing With Filters and Sorting

The list endpoint supports comprehensive filtering:

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)  # Exclude merged requests
    )

    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}%"),
            )
        )

    # Sorting
    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()))

    # Pagination
    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),
    }

Merged requests are excluded from the default listing. If request #42 was merged into request #17, only #17 appears. The merge target's upvote count includes votes from merged duplicates.

Duplicate Merging

One of the most useful admin operations is merging duplicate requests. When two developers independently request the same feature, we merge them:

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

    # Transfer upvotes (skip duplicates)
    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)

    # Transfer subscribers
    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)

    # Mark source as duplicate
    source.status = "duplicate"
    source.merged_into_id = target.id

    # Update target counts
    target.upvote_count = len(target.upvotes) + len(
        [u for u in source.upvotes if u.user_id not in existing_voters]
    )

    await db.commit()

    # Notify both sets of subscribers
    await notify_merge(source, target)

    return {"merged": source.id, "into": target.id}

Merging transfers upvotes and subscribers from the source to the target, skipping users who already upvoted or subscribed to the target. This ensures that the merged request accurately reflects total community interest.

Priority Levels: P0 Through P3

Priorities are set by admins during triage:

PriorityMeaningResponse Time Target
P0Critical -- blocking production usageSame day
P1High -- significant impact on developersThis week
P2Medium -- improves experience notablyThis month
P3Low -- nice to have, no urgencyWhen available
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 triggers immediate notification to all admins
        await notify_admins_p0(request)

    return request

P0 requests trigger immediate notification because they represent blocking issues. If a developer reports that payouts are failing for a specific provider, that is a P0 -- and it needs attention within hours, not days.

Subscriber Notifications

When a request changes status, all subscribers receive a notification. The request creator is automatically subscribed. Anyone who upvotes or comments is also automatically subscribed (though they can unsubscribe):

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}",
        )

What We Learned

Denormalized counts are essential for sorting. Counting upvotes via JOIN on every list request would be unacceptable at scale. The denormalized upvote_count makes "most voted" sorting a simple ORDER BY.

Duplicate merging is the most valuable admin feature. Developers describe the same need in different ways. Without merging, you get a fragmented view of what the community actually wants. With merging, the most-requested features naturally rise to the top.

Four categories are enough. We resisted the urge to add more. Every additional category is a decision the submitter must make, and too many choices lead to miscategorization. Bug, feature request, documentation, performance -- these cover everything.

Auto-subscribing on interaction is the right default. If someone cares enough to upvote or comment, they want to know when something changes. Opt-out is better than opt-in for engagement features.

The feature request module was built in a single session but it provides ongoing value. It is the primary way we understand what 0fee.dev developers need, and it directly feeds the product roadmap.


This article is part of the "How We Built 0fee.dev" series. 0fee.dev is a payment orchestrator covering 53+ providers across 200+ countries, built by Juste A. GNIMAVO and Claude from Abidjan with zero human engineers. Follow the series for the complete build story.

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles