feat: système de signalement bug/idée + modal changelog avec limitations
- AppReport entity + migration (type, message, fingerprint SHA-256, created_at) - POST /api/report avec rate-limit 5/heure par fingerprint IP - ReportModal : formulaire type radio (bug/idée) + textarea 1 000 chars max - ChangelogModal : fonctionnalités en prod, roadmap, 5 limitations honnêtes - Deux boutons flottants bas-gauche (? info + ✉ signalement) - Légende remontée pour éviter le chevauchement Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
35
backend/migrations/Version20260410000000.php
Normal file
35
backend/migrations/Version20260410000000.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260410000000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Crée la table app_report pour les retours bug/idée';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql("CREATE TABLE app_report (
|
||||
id UUID NOT NULL,
|
||||
type VARCHAR(10) NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
fingerprint VARCHAR(64) DEFAULT NULL,
|
||||
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
PRIMARY KEY (id)
|
||||
)");
|
||||
$this->addSql('CREATE INDEX idx_report_created_at ON app_report (created_at)');
|
||||
$this->addSql("COMMENT ON COLUMN app_report.id IS '(DC2Type:uuid)'");
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('DROP TABLE app_report');
|
||||
}
|
||||
}
|
||||
@@ -8,8 +8,8 @@
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Fredoka+One&family=Fredoka:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
<script type="module" crossorigin src="/assets/index-y0ONsdGe.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BcO1waH7.css">
|
||||
<script type="module" crossorigin src="/assets/index-MV1p6Xs0.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DZE-vnLk.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
67
backend/src/Controller/ReportController.php
Normal file
67
backend/src/Controller/ReportController.php
Normal file
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Entity\AppReport;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
#[Route('/api/report', name: 'api_report_')]
|
||||
class ReportController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private EntityManagerInterface $em,
|
||||
private Connection $connection,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* POST /api/report
|
||||
*
|
||||
* Body : { "type": "bug"|"idea", "message": "..." }
|
||||
* Rate limit : 5 reports/heure par fingerprint (hash IP).
|
||||
*/
|
||||
#[Route('', name: 'create', methods: ['POST'])]
|
||||
public function create(Request $request): JsonResponse
|
||||
{
|
||||
$body = json_decode($request->getContent(), true);
|
||||
|
||||
$type = trim((string) ($body['type'] ?? ''));
|
||||
$message = trim((string) ($body['message'] ?? ''));
|
||||
|
||||
if (!in_array($type, ['bug', 'idea'], true)) {
|
||||
return $this->json(['error' => 'type must be "bug" or "idea"'], 400);
|
||||
}
|
||||
if (strlen($message) < 5) {
|
||||
return $this->json(['error' => 'message too short (min 5 chars)'], 400);
|
||||
}
|
||||
if (strlen($message) > 1000) {
|
||||
return $this->json(['error' => 'message too long (max 1000 chars)'], 400);
|
||||
}
|
||||
|
||||
$fingerprint = hash('sha256', $request->getClientIp() ?? 'unknown');
|
||||
|
||||
// Rate limit : 5 rapports par heure par fingerprint
|
||||
$count = (int) $this->connection->fetchOne(
|
||||
"SELECT COUNT(*) FROM app_report
|
||||
WHERE fingerprint = :fp AND created_at >= NOW() - INTERVAL '1 hour'",
|
||||
['fp' => $fingerprint]
|
||||
);
|
||||
if ($count >= 5) {
|
||||
return $this->json(['error' => 'Rate limit exceeded. Try again later.'], 429);
|
||||
}
|
||||
|
||||
$report = new AppReport();
|
||||
$report->setType($type);
|
||||
$report->setMessage($message);
|
||||
$report->setFingerprint($fingerprint);
|
||||
|
||||
$this->em->persist($report);
|
||||
$this->em->flush();
|
||||
|
||||
return $this->json(['ok' => true], 201);
|
||||
}
|
||||
}
|
||||
47
backend/src/Entity/AppReport.php
Normal file
47
backend/src/Entity/AppReport.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Bridge\Doctrine\Types\UuidType;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'app_report')]
|
||||
#[ORM\Index(columns: ['created_at'], name: 'idx_report_created_at')]
|
||||
class AppReport
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\Column(type: UuidType::NAME, unique: true)]
|
||||
#[ORM\GeneratedValue(strategy: 'CUSTOM')]
|
||||
#[ORM\CustomIdGenerator(class: 'doctrine.uuid_generator')]
|
||||
private ?Uuid $id = null;
|
||||
|
||||
/** 'bug' ou 'idea' */
|
||||
#[ORM\Column(length: 10)]
|
||||
private string $type;
|
||||
|
||||
#[ORM\Column(type: 'text')]
|
||||
private string $message;
|
||||
|
||||
/** Hash SHA-256 de l'IP — jamais l'IP brute */
|
||||
#[ORM\Column(length: 64, nullable: true)]
|
||||
private ?string $fingerprint = null;
|
||||
|
||||
#[ORM\Column]
|
||||
private \DateTimeImmutable $createdAt;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->createdAt = new \DateTimeImmutable();
|
||||
}
|
||||
|
||||
public function getId(): ?Uuid { return $this->id; }
|
||||
public function getType(): string { return $this->type; }
|
||||
public function setType(string $type): static { $this->type = $type; return $this; }
|
||||
public function getMessage(): string { return $this->message; }
|
||||
public function setMessage(string $message): static { $this->message = $message; return $this; }
|
||||
public function getFingerprint(): ?string { return $this->fingerprint; }
|
||||
public function setFingerprint(?string $fingerprint): static { $this->fingerprint = $fingerprint; return $this; }
|
||||
public function getCreatedAt(): \DateTimeImmutable { return $this->createdAt; }
|
||||
}
|
||||
Reference in New Issue
Block a user