Skip to content

How-to: Content Reporting System

FT reference: FT289 (NENE2-FT/reportlog) — Content reporting: allowlisted reasons (ReportReason enum), UNIQUE(reporter_id, article_id) with idempotent 200 on duplicate, pending→resolved/dismissed state machine, moderator-only list/resolve/dismiss, DB-level CHECK constraints, 32 tests / 58 assertions PASS.

This guide shows how to build a content reporting system where users flag content and moderators review and resolve reports.

Schema

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

DB-level CHECK constraints enforce enum values even if application validation is bypassed.

Endpoints

MethodPathAuthDescription
POST/reportsX-User-IdSubmit a report
GET/reportsModeratorList all reports
GET/reports/{id}Reporter or ModeratorGet report
PUT/reports/{id}/resolveModeratorResolve report
PUT/reports/{id}/dismissModeratorDismiss report

ReportReason Enum

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

ReportReason::tryFrom($reasonStr) rejects unknown values. The handler returns valid reasons in the error response:

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

Idempotent Report Submission

If a user already reported the same article, return the existing report with 200 (not 201):

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

// First time: 201 Created
$id = $this->repository->createReport(...);
return $this->responseFactory->create($this->formatReport(...), 201);

UNIQUE(reporter_id, article_id) backs this up at the DB level. The application checks first to return a friendly response, but the UNIQUE constraint is the safety net.

Status Lifecycle

pending ──→ resolved (moderator action)
       └──→ dismissed (moderator action)

Once resolved or dismissed, a report cannot transition. Attempting to change a non-pending report returns 422:

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

Moderator Role Check

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

Role is stored in the users table and checked on every privileged operation. A DB-level CHECK (role IN ('user', 'moderator')) prevents invalid roles from being inserted.

Access Control: Reporter vs Moderator

GET /reports/{id} is accessible to both the original reporter and moderators:

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

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

Reporters can view their own reports to track status. Moderators see all reports.

Resolution with Audit Trail

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

resolved_by (moderator ID), resolved_at (timestamp), and resolution_note (optional) create an audit trail for every moderation action.


What NOT to do

Anti-patternRisk
Accept free-form reason stringTypos, injection, infinite categories; use enum allowlist
No UNIQUE(reporter_id, article_id)Same user submits dozens of reports for same article; bloated queue
Return 409 on duplicate reportRetry-safe idempotency: duplicate → 200 with existing report, not error
Allow transition from resolved/dismissedResolved report re-opened; audit trail becomes unreliable
No moderator role check on list/resolveAny user reads all reports; privacy violation + audit bypass
Return reporter's own report to another userIDOR — always check reporter === actor or actor is moderator
No resolution_note fieldModerators cannot communicate why a report was dismissed vs resolved
No resolved_by fieldCannot audit which moderator took action
DB CHECK only, no app validationDB throws exception on invalid reason; user gets 500 instead of 422

Released under the MIT License.