Skip to content

How-to: Upvote / Downvote API

FT reference: FT347 (NENE2-FT/votelog) — Per-user upvote/downvote with toggle-off (same direction twice removes vote), direction change (up→down atomically), score aggregation (upvotes − downvotes), UNIQUE(user_id, item_id) constraint, 15 tests PASS.

This guide shows how to implement a Reddit/Stack Overflow–style voting system: each user can cast one vote per item, toggle it off by re-voting in the same direction, or change direction.

Schema

sql
CREATE TABLE users (
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
    name       TEXT    NOT NULL,
    created_at TEXT    NOT NULL
);

CREATE TABLE items (
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
    title      TEXT    NOT NULL,
    created_at TEXT    NOT NULL
);

CREATE TABLE votes (
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
    user_id    INTEGER NOT NULL,
    item_id    INTEGER NOT NULL,
    direction  TEXT    NOT NULL CHECK (direction IN ('up', 'down')),
    created_at TEXT    NOT NULL,
    UNIQUE (user_id, item_id),
    FOREIGN KEY (user_id)  REFERENCES users(id),
    FOREIGN KEY (item_id)  REFERENCES items(id)
);

UNIQUE(user_id, item_id) enforces one vote per user per item. CHECK(direction IN ('up', 'down')) rejects any other value at the DB level.

Endpoints

MethodPathDescription
POST/items/{id}/voteCast, toggle, or change vote
GET/items/{id}/scoreGet item score
GET/items/{id}/vote/{userId}Get user's current vote

Cast a Vote

php
POST /items/1/vote
{"user_id": 42, "direction": "up"}

 200
{
  "vote": "up",
  "score": {
    "upvotes": 1,
    "downvotes": 0,
    "score": 1
  }
}
php
POST /items/1/vote
{"user_id": 43, "direction": "down"}
 200  {"vote": "down", "score": {"upvotes": 1, "downvotes": 1, "score": 0}}

Toggle Off (Same Direction Twice)

Voting in the same direction a second time removes the vote:

php
// First vote
POST /items/1/vote  {"user_id": 42, "direction": "up"}
 200  {"vote": "up", "score": {"score": 1}}

// Second vote in same direction → toggle off
POST /items/1/vote  {"user_id": 42, "direction": "up"}
 200  {"vote": null, "score": {"score": 0}}

vote: null means the user has no active vote on this item.

Change Direction

Voting in the opposite direction flips the existing vote atomically:

php
// Start with upvote
POST /items/1/vote  {"user_id": 42, "direction": "up"}
 200  {"vote": "up", "score": {"upvotes": 1, "downvotes": 0, "score": 1}}

// Change to downvote
POST /items/1/vote  {"user_id": 42, "direction": "down"}
 200  {"vote": "down", "score": {"upvotes": 0, "downvotes": 1, "score": -1}}

Get Score

php
GET /items/1/score

 200
{
  "upvotes": 2,
  "downvotes": 1,
  "score": 1         // upvotes − downvotes
}

Score for an item with no votes:

php
GET /items/1/score   // no votes yet
 200  {"upvotes": 0, "downvotes": 0, "score": 0}

Get User Vote State

php
// No vote cast yet
GET /items/1/vote/42
 200  {"vote": null}

// After upvote
GET /items/1/vote/42
 200  {"vote": "up"}

// After toggle-off
GET /items/1/vote/42
 200  {"vote": null}

Implementation

Vote Handler Logic

php
public function vote(int $itemId, int $userId, string $direction): array
{
    $item = $this->repo->findItem($itemId);
    if ($item === null) {
        throw new ItemNotFoundException($itemId);
    }

    $existing = $this->repo->findVote($userId, $itemId);

    if ($existing !== null && $existing['direction'] === $direction) {
        // Same direction → toggle off
        $this->repo->deleteVote($userId, $itemId);
        $activeDirection = null;
    } elseif ($existing !== null) {
        // Different direction → update in place
        $this->repo->updateVote($userId, $itemId, $direction, $now);
        $activeDirection = $direction;
    } else {
        // New vote
        $this->repo->insertVote($userId, $itemId, $direction, $now);
        $activeDirection = $direction;
    }

    $score = $this->repo->getScore($itemId);

    return [
        'vote'  => $activeDirection,
        'score' => $score,
    ];
}

Score Aggregation SQL

sql
SELECT
    COALESCE(SUM(CASE WHEN direction = 'up'   THEN 1 ELSE 0 END), 0) AS upvotes,
    COALESCE(SUM(CASE WHEN direction = 'down' THEN 1 ELSE 0 END), 0) AS downvotes,
    COALESCE(SUM(CASE WHEN direction = 'up'   THEN 1 ELSE -1 END), 0) AS score
FROM votes
WHERE item_id = ?

COALESCE(..., 0) ensures zero values when no votes exist (SUM of empty set returns NULL).

Vote UPSERT Pattern

sql
-- Insert new vote
INSERT INTO votes (user_id, item_id, direction, created_at) VALUES (?, ?, ?, ?)

-- Update direction (UNIQUE constraint prevents duplicate)
UPDATE votes SET direction = ? WHERE user_id = ? AND item_id = ?

-- Delete (toggle off)
DELETE FROM votes WHERE user_id = ? AND item_id = ?

Validation

php
// Invalid direction
POST /items/1/vote  {"user_id": 42, "direction": "sideways"}
 422  // direction must be 'up' or 'down'

// Item does not exist
POST /items/9999/vote  {"user_id": 42, "direction": "up"}
 404

Multiple Users

php
// Three users vote on the same item
POST /items/1/vote  {"user_id": 1, "direction": "up"}
POST /items/1/vote  {"user_id": 2, "direction": "up"}
POST /items/1/vote  {"user_id": 3, "direction": "down"}

GET /items/1/score
 200  {"upvotes": 2, "downvotes": 1, "score": 1}

What NOT to do

Anti-patternRisk
No UNIQUE(user_id, item_id)Users can vote multiple times, inflating scores
INSERT OR REPLACE for direction changeGenerates a new id and created_at; loses vote history; breaks audit trails
Return 409 on toggle-offToggle-off is expected behaviour, not an error; return the new (null) vote state
Compute score in application by fetching all votesO(N) per request; use SQL aggregation with a single query
Allow direction: null to remove vote via bodyAmbiguous; use the toggle pattern (same direction twice) or a separate DELETE endpoint
Skip COALESCE in score aggregationSUM() returns NULL when no rows match; null − null crashes or returns wrong type

Released under the MIT License.