Skip to content

How-to : Système de signalement de contenu

Référence FT : FT289 (NENE2-FT/reportlog) — Signalement de contenu : raisons en liste d'autorisation (enum ReportReason), UNIQUE(reporter_id, article_id) avec idempotence 200 sur doublon, machine à états pending→resolved/dismissed, liste/resolve/dismiss réservés aux modérateurs, contraintes CHECK au niveau DB, 32 tests / 58 assertions PASS.

Ce guide montre comment construire un système de signalement de contenu où les utilisateurs signalent du contenu et les modérateurs examinent et résolvent les signalements.

Schéma

sql
CREATE TABLE users (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    name TEXT NOT NULL,
    role TEXT NOT NULL DEFAULT 'user',
    created_at TEXT NOT NULL,
    CHECK (role IN ('user', 'moderator'))
);

CREATE TABLE reports (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    reporter_id INTEGER NOT NULL,
    article_id INTEGER NOT NULL,
    reason TEXT NOT NULL,
    details TEXT,
    status TEXT NOT NULL DEFAULT 'pending',
    resolved_by INTEGER,
    resolved_at TEXT,
    resolution_note TEXT,
    created_at TEXT NOT NULL,
    UNIQUE (reporter_id, article_id),
    CHECK (status IN ('pending', 'resolved', 'dismissed')),
    CHECK (reason IN ('spam', 'harassment', 'misinformation', 'other')),
    FOREIGN KEY (reporter_id) REFERENCES users(id),
    FOREIGN KEY (article_id) REFERENCES articles(id),
    FOREIGN KEY (resolved_by) REFERENCES users(id)
);

Les contraintes CHECK au niveau DB appliquent les valeurs d'enum même si la validation applicative est contournée.

Endpoints

MéthodeCheminAuthDescription
POST/reportsX-User-IdSoumettre un signalement
GET/reportsModérateurLister tous les signalements
GET/reports/{id}Signalant ou ModérateurObtenir un signalement
PUT/reports/{id}/resolveModérateurRésoudre le signalement
PUT/reports/{id}/dismissModérateurRejeter le signalement

Enum ReportReason

php
enum ReportReason: string
{
    case Spam         = 'spam';
    case Harassment   = 'harassment';
    case Misinformation = 'misinformation';
    case Other        = 'other';
}

ReportReason::tryFrom($reasonStr) rejette les valeurs inconnues. Le gestionnaire retourne les raisons valides dans la réponse d'erreur :

php
$reason = ReportReason::tryFrom($reasonStr);
if ($reason === null) {
    $validReasons = array_map(fn(ReportReason $r) => $r->value, ReportReason::cases());
    return $this->responseFactory->create(['error' => 'invalid reason', 'valid_reasons' => $validReasons], 422);
}

Soumission de signalement idempotente

Si un utilisateur a déjà signalé le même article, retourner le signalement existant avec 200 (pas 201) :

php
$existing = $this->repository->findReportByReporterAndArticle($actorId, $articleId);
if ($existing !== null) {
    return $this->responseFactory->create($this->formatReport($existing), 200);
}

// Première fois : 201 Created
$id = $this->repository->createReport(...);
return $this->responseFactory->create($this->formatReport(...), 201);

UNIQUE(reporter_id, article_id) est le filet de sécurité au niveau DB. L'application vérifie d'abord pour retourner une réponse conviviale, mais la contrainte UNIQUE est le vrai garde-fou.

Cycle de vie du statut

pending ──→ resolved (action du modérateur)
       └──→ dismissed (action du modérateur)

Une fois résolu ou rejeté, un signalement ne peut plus transitionner. Tenter de modifier un signalement non-pending retourne 422 :

php
if ($report['status'] !== 'pending') {
    return $this->responseFactory->create([
        'error' => 'report is not pending',
        'current_status' => $report['status'],
    ], 422);
}

Vérification du rôle modérateur

php
$actor = $this->repository->findUserById($actorId);
if ($actor === null || $actor['role'] !== 'moderator') {
    return $this->responseFactory->create(['error' => 'moderator role required'], 403);
}

Le rôle est stocké dans la table users et vérifié à chaque opération privilégiée. Un CHECK (role IN ('user', 'moderator')) au niveau DB empêche l'insertion de rôles invalides.

Contrôle d'accès : Signalant vs Modérateur

GET /reports/{id} est accessible au signalant original et aux modérateurs :

php
$isModerator = $actor['role'] === 'moderator';
$isReporter  = (int)$report['reporter_id'] === $actorId;

if (!$isModerator && !$isReporter) {
    return $this->responseFactory->create(['error' => 'access denied'], 403);
}

Les signalants peuvent voir leurs propres signalements pour suivre leur statut. Les modérateurs voient tous les signalements.

Résolution avec piste d'audit

php
$this->repository->updateReportStatus($id, $newStatus, $actorId, date('c'), $note);

resolved_by (ID du modérateur), resolved_at (horodatage) et resolution_note (optionnel) créent une piste d'audit pour chaque action de modération.


Ce qu'il ne faut PAS faire

Anti-patternRisque
Accepter des chaînes de raison libresFautes de frappe, injection, catégories infinies ; utiliser une liste d'autorisation d'enum
Pas de UNIQUE(reporter_id, article_id)Le même utilisateur soumet des dizaines de signalements pour le même article ; file d'attente gonflée
Retourner 409 sur un signalement en doublonIdempotence sûre au retry : doublon → 200 avec signalement existant, pas une erreur
Autoriser la transition depuis resolved/dismissedSignalement résolu réouvert ; piste d'audit devient peu fiable
Pas de vérification du rôle modérateur sur list/resolveN'importe quel utilisateur lit tous les signalements ; violation de confidentialité + contournement d'audit
Retourner le signalement d'un autre utilisateurIDOR — vérifier toujours signalant === acteur ou acteur est modérateur
Pas de champ resolution_noteLes modérateurs ne peuvent pas communiquer pourquoi un signalement a été rejeté vs résolu
Pas de champ resolved_byImpossible d'auditer quel modérateur a agi
CHECK DB uniquement, pas de validation applicativeLa DB lance une exception sur une raison invalide ; l'utilisateur reçoit 500 au lieu de 422

Publié sous licence MIT.