Skip to content

How-to : Système de liste d'attente

Référence FT : FT287 (NENE2-FT/waitlistlog) — Système de liste d'attente : contrainte UNIQUE(user_id) pour une entrée par utilisateur, machine d'états waiting→approved/declined, garde isTerminal(), /waitlist/me enregistré avant /{id} pour éviter la capture de route, authentification X-Admin-Key, suivi de position dans la file, 39 tests / 98 assertions PASS.

Ce guide montre comment construire un système de liste d'attente où les utilisateurs rejoignent une file et les administrateurs approuvent ou refusent les entrées.

Schéma

sql
CREATE TABLE IF NOT EXISTS waitlist_entries (
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
    user_id    INTEGER NOT NULL UNIQUE,   -- une entrée par utilisateur
    status     TEXT    NOT NULL DEFAULT 'waiting',  -- waiting | approved | declined
    note       TEXT,                               -- note optionnelle fournie par l'utilisateur (max 500 chars)
    created_at TEXT    NOT NULL,
    updated_at TEXT    NOT NULL
);

user_id UNIQUE applique une entrée par utilisateur au niveau DB — aucune vérification applicative n'est nécessaire pour les conditions de concurrence.

Endpoints

MéthodeCheminAuthDescription
POST/waitlistX-User-IdRejoindre la liste d'attente
GET/waitlist/meX-User-IdObtenir son statut + position
DELETE/waitlist/meX-User-IdQuitter la liste d'attente
GET/waitlistX-Admin-KeyAdmin : lister toutes les entrées
POST/waitlist/{id}/approveX-Admin-KeyAdmin : approuver une entrée
POST/waitlist/{id}/declineX-Admin-KeyAdmin : refuser une entrée

Ordre d'enregistrement des routes

/waitlist/me doit être enregistré avant /waitlist/{id} pour éviter que le paramètre de chemin ne capture la chaîne littérale "me" :

php
// CORRECT : chemin statique avant chemin dynamique
$this->router->get('/waitlist/me', $this->handleMe(...));
$this->router->post('/waitlist/{id}/approve', $this->handleApprove(...));

// INCORRECT : {id} capturerait "me"
$this->router->post('/waitlist/{id}/approve', $this->handleApprove(...));
$this->router->get('/waitlist/me', $this->handleMe(...));  // jamais atteint

Cycle de vie du statut

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

Une fois approuvée ou refusée, une entrée ne peut pas passer à un autre état. La méthode isTerminal() assure cette garde :

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

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

Rejoindre avec 409 sur doublon

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

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

Le repository retourne null quand user_id existe déjà (capturé depuis DatabaseConstraintException). La réponse est 409 Conflict.

Suivi de position

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

// positionOf() compte les entrées avec status='waiting' et id <= $entry->id
// SELECT COUNT(*) FROM waitlist_entries WHERE status = 'waiting' AND id <= ?

La position est le rang 1-based dans la file waiting. Les entrées approuvées/refusées ne comptent pas. Cela donne aux utilisateurs une place significative dans la file.

Transition admin avec 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 est exhaustif — le cas default capture toute valeur de retour inattendue du repository.

Partir (uniquement en attente)

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

Une fois approuvé ou refusé, un utilisateur ne peut plus partir — sa décision est enregistrée. Cela empêche de contourner le système (approuver puis partir pour éviter le suivi).

Authentification admin

php
private function isAdmin(ServerRequestInterface $request): bool
{
    if ($this->adminKey === '') {
        return false;  // fail-closed : pas de clé configurée → pas d'accès admin
    }
    return hash_equals($this->adminKey, $request->getHeaderLine('X-Admin-Key'));
}

hash_equals() prévient les attaques temporelles. Une clé admin vide retourne toujours false (fail-closed).

Validation de la note

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;
}

Les notes sont optionnelles (null si absentes/vides), max 500 caractères, tronquées (non rejetées) si trop longues.


Ce qu'il ne faut PAS faire

Anti-patternRisque
Pas de contrainte UNIQUE(user_id)Des inscriptions concurrentes créent des entrées dupliquées ; condition de concurrence
Enregistrer /{id} avant /me/waitlist/me devient inaccessible — capturé par {id} qui correspond à "me"
Autoriser la transition depuis l'état terminalEntrée approuvée refusée après l'octroi d'accès ; machine d'états brisée
Autoriser le départ depuis l'état terminalL'utilisateur approuvé part ; l'octroi d'accès devient orphelin
Retourner la position en comptant toutes les entrées par id ASCCompte les utilisateurs approuvés/refusés ; le numéro de position est trompeur
Stocker la clé admin en DBLa rotation de clé nécessite une mise à jour DB ; utiliser une variable d'environnement
Utiliser == au lieu de hash_equals() pour la clé adminUne attaque temporelle révèle la clé caractère par caractère
Pas de fail-closed pour l'adminUne clé vide dans l'env autorise l'accès admin non authentifié
Rejeter la note si trop longueUX : tronquer est plus convivial que rejeter pour les métadonnées souples comme les notes

Publié sous licence MIT.