Skip to content

How-to: Wartelistensystem

FT-Referenz: FT287 (NENE2-FT/waitlistlog) — Wartelistensystem: UNIQUE(user_id) Ein-Eintrag-Constraint, waiting→approved/declined-Zustandsmaschine, isTerminal()-Guard, /waitlist/me vor /{id} registriert um Route-Capture zu verhindern, X-Admin-Key-Authentifizierung, Queue-Positionsverfolgung, 39 Tests / 98 Assertions BESTANDEN.

Diese Anleitung zeigt, wie ein Wartelistensystem aufgebaut wird, bei dem Benutzer einer Warteschlange beitreten und Administratoren Einträge genehmigen oder ablehnen.

Schema

sql
CREATE TABLE IF NOT EXISTS waitlist_entries (
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
    user_id    INTEGER NOT NULL UNIQUE,   -- ein Eintrag pro Benutzer
    status     TEXT    NOT NULL DEFAULT 'waiting',  -- waiting | approved | declined
    note       TEXT,                               -- optionale Benutzernotiz (max 500 Zeichen)
    created_at TEXT    NOT NULL,
    updated_at TEXT    NOT NULL
);

user_id UNIQUE setzt einen Eintrag pro Benutzer auf DB-Ebene durch — keine Anwendungsschicht-Prüfung für Race Conditions erforderlich.

Endpunkte

MethodePfadAuthBeschreibung
POST/waitlistX-User-IdWarteliste beitreten
GET/waitlist/meX-User-IdEigenen Status + Position abrufen
DELETE/waitlist/meX-User-IdWarteliste verlassen
GET/waitlistX-Admin-KeyAdmin: alle Einträge auflisten
POST/waitlist/{id}/approveX-Admin-KeyAdmin: Eintrag genehmigen
POST/waitlist/{id}/declineX-Admin-KeyAdmin: Eintrag ablehnen

Routenregistrierungsreihenfolge

/waitlist/me muss vor /waitlist/{id} registriert werden, um zu verhindern, dass der Pfadparameter den wörtlichen String "me" erfasst:

php
// KORREKT: statischer Pfad vor dynamischem Pfad
$this->router->get('/waitlist/me', $this->handleMe(...));
$this->router->post('/waitlist/{id}/approve', $this->handleApprove(...));

// FALSCH: {id} würde "me" erfassen
$this->router->post('/waitlist/{id}/approve', $this->handleApprove(...));
$this->router->get('/waitlist/me', $this->handleMe(...));  // wird nie erreicht

Status-Lebenszyklus

waiting ──────→ approved (terminal)
       └──────→ declined (terminal)

Einmal genehmigt oder abgelehnt, kann ein Eintrag nicht in einen anderen Zustand übergehen. Die isTerminal()-Methode schützt dies:

php
enum WaitlistStatus: string
{
    case Waiting  = 'waiting';
    case Approved = 'approved';
    case Declined = 'declined';

    public function isTerminal(): bool
    {
        return $this !== self::Waiting;
    }
}

Beitreten mit 409 bei Duplikat

php
$entry = $this->repository->join($userId, $note);

if ($entry === null) {
    return $this->responseFactory->create(['error' => 'Already on the waitlist.'], 409);
}

Das Repository gibt null zurück, wenn user_id bereits existiert (aus DatabaseConstraintException abgefangen). Die Antwort ist 409 Conflict.

Positionsverfolgung

php
$position = $this->repository->positionOf($entry);

// positionOf() zählt Einträge mit status='waiting' und id <= $entry->id
// SELECT COUNT(*) FROM waitlist_entries WHERE status = 'waiting' AND id <= ?

Position ist der 1-basierte Rang in der waiting-Warteschlange. Genehmigte/abgelehnte Einträge zählen nicht. Das gibt Benutzern einen aussagekräftigen Platz in der Schlange.

Admin-Übergang mit match

php
private function handleTransition(int $id, WaitlistStatus $newStatus): ResponseInterface
{
    $result = $this->repository->transition($id, $newStatus);

    return match ($result) {
        'ok'               => $this->responseFactory->create(['status' => $newStatus->value]),
        'not_found'        => $this->responseFactory->create(['error' => 'Entry not found.'], 404),
        'already_terminal' => $this->responseFactory->create(['error' => 'Entry is already approved or declined.'], 409),
        default            => $this->responseFactory->create(['error' => 'Unexpected error.'], 500),
    };
}

match ist erschöpfend — der default-Fall fängt unerwartete Rückgabewerte aus dem Repository ab.

Verlassen (nur im Waiting-Status)

php
return match ($result) {
    'removed'     => $this->responseFactory->create(['removed' => true], 200),
    'not_found'   => $this->responseFactory->create(['error' => 'Not on the waitlist.'], 404),
    'not_waiting' => $this->responseFactory->create(['error' => 'Cannot leave — status is no longer waiting.'], 409),
    default       => $this->responseFactory->create(['error' => 'Unexpected error.'], 500),
};

Einmal genehmigt oder abgelehnt, kann ein Benutzer die Warteliste nicht mehr verlassen — seine Entscheidung ist festgehalten. Das verhindert das Austricksen des Systems (genehmigen, dann verlassen um Tracking zu vermeiden).

Admin-Authentifizierung

php
private function isAdmin(ServerRequestInterface $request): bool
{
    if ($this->adminKey === '') {
        return false;  // fail-closed: kein konfigurierter Schlüssel → kein Admin-Zugriff
    }
    return hash_equals($this->adminKey, $request->getHeaderLine('X-Admin-Key'));
}

hash_equals() verhindert Timing-Angriffe. Leerer Admin-Key gibt immer false zurück (fail-closed).

Notiz-Validierung

php
private const int MAX_NOTE_LEN = 500;

private function resolveNote(mixed $raw): ?string
{
    if (!is_string($raw) || trim($raw) === '') {
        return null;
    }
    return mb_strlen($raw) > self::MAX_NOTE_LEN ? mb_substr($raw, 0, self::MAX_NOTE_LEN) : $raw;
}

Notizen sind optional (null wenn fehlend/leer), max 500 Zeichen, werden bei Überschreitung abgeschnitten (nicht abgelehnt).


Was NICHT zu tun ist

Anti-MusterRisiko
Kein UNIQUE(user_id)-ConstraintGleichzeitige Beitritte erzeugen doppelte Einträge; Race Condition
/{id} vor /me registrieren/waitlist/me wird unerreichbar — von {id} mit "me" erfasst
Übergang aus Terminal-Zustand erlaubenGenehmigter Eintrag nach Zugangsgewährung abgelehnt; Zustandsmaschine kaputt
Verlassen aus Terminal-Zustand erlaubenGenehmigter Benutzer verlässt; Zugangsgewährung wird verwaist
Position basierend auf id ASC mit allen Einträgen berechnenZählt genehmigte/abgelehnte Benutzer; Positionsnummer ist irreführend
Admin-Key in DB speichernKey-Rotation erfordert DB-Update; stattdessen Env-Var verwenden
== statt hash_equals() für Admin-KeyTiming-Angriff enthüllt Key ein Zeichen nach dem anderen
Kein Admin-Fail-ClosedLeerer Key in Env erlaubt unauthentifizierten Admin-Zugriff
Notiz ablehnen wenn zu langUX: Abschneiden ist benutzerfreundlicher als Ablehnen für weiche Metadaten wie Notizen

Veröffentlicht unter der MIT-Lizenz.