Skip to content

Abstimmungssystem (Upvote / Downvote) ​

Benutzern erlauben, Elemente hoch- oder herunterzustufen. Jeder Benutzer kann pro Element maximal eine Stimme abgeben. Zweimal in dieselbe Richtung abstimmen schaltet die Stimme aus. In die entgegengesetzte Richtung abstimmen wechselt die Stimme.

Überblick ​

Ein Abstimmungssystem umfasst:

  • Stimme abgeben: Element hoch- oder herunterstimmen
  • Umschalten: Zweimal in dieselbe Richtung abstimmen entfernt die Stimme
  • Wechseln: In die entgegengesetzte Richtung abstimmen ersetzt die aktuelle Stimme
  • Score: Upvotes − Downvotes, mit jeder Stimm-Antwort zurückgegeben
  • Aktuelle Stimme: Aktuelle Stimme eines Benutzers für ein Element abrufen (für UI-Hervorhebung)

Datenbankschema ​

sql
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)
);

Der UNIQUE (user_id, item_id)-Constraint setzt eine Stimme pro Benutzer pro Element auf Datenbankebene durch. CHECK (direction IN ('up', 'down')) verhindert ungültige Werte, auch wenn die Validierung auf Anwendungsebene umgangen wird.

Richtung als Enum ​

Ein backed Enum verwenden, um ungültige Richtungswerte zu verhindern, bevor sie das Repository erreichen:

php
enum VoteDirection: string
{
    case Up   = 'up';
    case Down = 'down';
}

Mit VoteDirection::tryFrom($dirStr) parsen — gibt null für ungültige Eingaben zurück und ermöglicht eine saubere 422-Behandlung ohne match/switch.

Toggle- und Switch-Logik ​

Alle drei Fälle (Toggle-Off, Richtungswechsel, neue Stimme) werden im Repository behandelt:

php
public function castVote(int $userId, int $itemId, VoteDirection $direction, string $now): ?VoteDirection
{
    $current = $this->getCurrentVote($userId, $itemId);

    if ($current === $direction) {
        // gleiche Richtung → Toggle-Off
        $this->executor->execute(
            'DELETE FROM votes WHERE user_id = ? AND item_id = ?',
            [$userId, $itemId],
        );
        return null;
    }

    if ($current !== null) {
        // andere Richtung → wechseln
        $this->executor->execute(
            'UPDATE votes SET direction = ?, created_at = ? WHERE user_id = ? AND item_id = ?',
            [$direction->value, $now, $userId, $itemId],
        );
    } else {
        // keine vorhandene Stimme → einfügen
        $this->executor->execute(
            'INSERT INTO votes (user_id, item_id, direction, created_at) VALUES (?, ?, ?, ?)',
            [$userId, $itemId, $direction->value, $now],
        );
    }

    return $direction;
}

Der Rückgabewert ?VoteDirection lässt den Handler wissen, ob die Stimme jetzt gesetzt ('up'/'down') oder entfernt (null) ist.

Score mit jeder Stimme zurückgeben ​

Den aktualisierten Score in die Stimmantwort einschließen, damit Clients Zähler ohne ein separates GET aktualisieren können:

php
$result = $this->repo->castVote($userId, $itemId, $direction, $now);
$score  = $this->repo->getScore($itemId);

return $this->responseFactory->create([
    'user_id' => $userId,
    'item_id' => $itemId,
    'vote'    => $result !== null ? $result->value : null,
    'score'   => $score->toArray(),
]);

Score-Berechnung ​

Separate COUNT-Abfragen pro Richtung sind einfacher und lesbarer als ein einzelnes GROUP BY:

php
public function getScore(int $itemId): ItemScore
{
    $upRow = $this->executor->fetchOne(
        "SELECT COUNT(*) as cnt FROM votes WHERE item_id = ? AND direction = 'up'",
        [$itemId],
    );
    $downRow = $this->executor->fetchOne(
        "SELECT COUNT(*) as cnt FROM votes WHERE item_id = ? AND direction = 'down'",
        [$itemId],
    );
    ...
}

score = upvotes - downvotes. Null ist der Ausgangszustand, bevor Stimmen abgegeben wurden.

Benutzer-Stimmstatus ​

Ein separater Endpunkt lässt die UI zeigen, in welche Richtung der aktuelle Benutzer gestimmt hat (für Button-Hervorhebung):

php
// GET /items/{itemId}/vote/{userId}
$current = $this->repo->getCurrentVote($userId, $itemId);
return ['vote' => $current !== null ? $current->value : null];

Gibt null zurück, wenn der Benutzer nicht gestimmt hat (oder seine Stimme umgeschaltet hat).

Sicherheitseigenschaften ​

EigenschaftImplementierung
Eine Stimme pro Benutzer pro ElementUNIQUE (user_id, item_id) DB-Constraint
Ungültige Richtung abgelehntCHECK (direction IN ('up', 'down')) + VoteDirection::tryFrom()
Unbekannter Benutzer/ElementGibt 404 zurück — keine Ressourcenexistenz geleakt
Toggle-SicherheitPrüft aktuelle Stimme vor DELETE/UPDATE

Routenübersicht ​

MethodePfadBeschreibung
POST/usersBenutzer erstellen
POST/itemsElement erstellen
POST/items/{itemId}/voteStimme abgeben, wechseln oder umschalten
GET/items/{itemId}/scoreUpvotes, Downvotes und Score abrufen
GET/items/{itemId}/vote/{userId}Aktuelle Stimme eines Benutzers für ein Element abrufen

Veröffentlicht unter der MIT-Lizenz.