feat: ajout du suivi des suggestions avec intégration Gitea
This commit is contained in:
@@ -2,10 +2,12 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\Suggestion;
|
||||||
use App\Models\Utilisateur;
|
use App\Models\Utilisateur;
|
||||||
use App\Services\GiteaIssueService;
|
use App\Services\GiteaIssueService;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
class SuggestionController extends Controller
|
class SuggestionController extends Controller
|
||||||
@@ -46,13 +48,104 @@ class SuggestionController extends Controller
|
|||||||
."---\n"
|
."---\n"
|
||||||
.'_Ticket créé automatiquement depuis le formulaire de suggestions du site._';
|
.'_Ticket créé automatiquement depuis le formulaire de suggestions du site._';
|
||||||
|
|
||||||
if ($gitea->createIssue($title, $body)) {
|
$numero = $gitea->createIssue($title, $body);
|
||||||
return redirect()->route('suggestions.create')
|
|
||||||
->with('success', 'Merci ! Votre suggestion a bien été transmise à l\'équipe.');
|
if ($numero === null) {
|
||||||
|
return back()->withInput()->withErrors([
|
||||||
|
'gitea' => "L'envoi de votre suggestion a échoué. Merci de réessayer plus tard.",
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return back()->withInput()->withErrors([
|
// La suggestion est conservée en base, liée au ticket, pour le suivi.
|
||||||
'gitea' => "L'envoi de votre suggestion a échoué. Merci de réessayer plus tard.",
|
Suggestion::create([
|
||||||
|
'ID_Utilisateur' => $user->ID_Utilisateur,
|
||||||
|
'Type' => $data['type'],
|
||||||
|
'Titre' => $data['titre'],
|
||||||
|
'Description' => $data['description'],
|
||||||
|
'GiteaIssueNumber' => $numero,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
return redirect()->route('suggestions.index')
|
||||||
|
->with('success', 'Merci ! Votre suggestion a bien été transmise à l\'équipe. Vous pouvez suivre sa prise en compte ici.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Liste des suggestions de l'utilisateur, avec l'état de chaque ticket
|
||||||
|
public function index(GiteaIssueService $gitea)
|
||||||
|
{
|
||||||
|
$suggestions = Suggestion::where('ID_Utilisateur', Auth::user()->ID_Utilisateur)
|
||||||
|
->orderByDesc('DateCreation')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
// État des tickets (ouvert/fermé, nb de réponses), mis en cache 60 s
|
||||||
|
// pour ne pas appeler l'API Gitea à chaque affichage de la page.
|
||||||
|
$etats = [];
|
||||||
|
|
||||||
|
foreach ($suggestions as $suggestion) {
|
||||||
|
if ($suggestion->GiteaIssueNumber === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$etat = Cache::remember(
|
||||||
|
'gitea.issue.'.$suggestion->GiteaIssueNumber,
|
||||||
|
60,
|
||||||
|
fn () => $gitea->getIssue($suggestion->GiteaIssueNumber) ?? false,
|
||||||
|
);
|
||||||
|
|
||||||
|
$etats[$suggestion->ID_Suggestion] = $etat ?: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return view('suggestions.index', compact('suggestions', 'etats'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Détail d'une suggestion : fil de discussion du ticket Gitea
|
||||||
|
public function show(Suggestion $suggestion, GiteaIssueService $gitea)
|
||||||
|
{
|
||||||
|
abort_unless($suggestion->ID_Utilisateur === Auth::user()->ID_Utilisateur, 403);
|
||||||
|
|
||||||
|
$issue = null;
|
||||||
|
$commentaires = [];
|
||||||
|
|
||||||
|
if ($suggestion->GiteaIssueNumber !== null) {
|
||||||
|
$issue = $gitea->getIssue($suggestion->GiteaIssueNumber);
|
||||||
|
$commentaires = $gitea->getComments($suggestion->GiteaIssueNumber);
|
||||||
|
|
||||||
|
// Tout est lu : le badge « nouvelle réponse » disparaît de la liste.
|
||||||
|
$suggestion->CommentairesVus = count($commentaires);
|
||||||
|
$suggestion->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
return view('suggestions.show', compact('suggestion', 'issue', 'commentaires'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Répondre sur le ticket : le commentaire est publié sur Gitea
|
||||||
|
public function repondre(Request $request, Suggestion $suggestion, GiteaIssueService $gitea)
|
||||||
|
{
|
||||||
|
abort_unless($suggestion->ID_Utilisateur === Auth::user()->ID_Utilisateur, 403);
|
||||||
|
abort_if($suggestion->GiteaIssueNumber === null, 404);
|
||||||
|
|
||||||
|
$data = $request->validate([
|
||||||
|
'message' => ['required', 'string', 'min:2', 'max:2000'],
|
||||||
|
], [
|
||||||
|
'message.required' => 'Votre message est vide.',
|
||||||
|
'message.min' => 'Votre message est trop court.',
|
||||||
|
'message.max' => 'Votre message ne peut pas dépasser 2000 caractères.',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** @var Utilisateur $user */
|
||||||
|
$user = Auth::user();
|
||||||
|
|
||||||
|
$corps = '**'.($user->Pseudo ?? 'Utilisateur')."** (via le site) :\n\n".$data['message'];
|
||||||
|
|
||||||
|
if (! $gitea->addComment($suggestion->GiteaIssueNumber, $corps)) {
|
||||||
|
return back()->withInput()->withErrors([
|
||||||
|
'gitea' => "L'envoi de votre réponse a échoué. Merci de réessayer plus tard.",
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// L'état du ticket a changé : on invalide le cache de la liste.
|
||||||
|
Cache::forget('gitea.issue.'.$suggestion->GiteaIssueNumber);
|
||||||
|
|
||||||
|
return redirect()->route('suggestions.show', $suggestion)
|
||||||
|
->with('success', 'Votre réponse a été publiée sur le ticket.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @property int $ID_Suggestion
|
||||||
|
* @property int $ID_Utilisateur
|
||||||
|
* @property string $Type
|
||||||
|
* @property string $Titre
|
||||||
|
* @property string $Description
|
||||||
|
* @property int|null $GiteaIssueNumber
|
||||||
|
* @property int $CommentairesVus
|
||||||
|
* @property Carbon $DateCreation
|
||||||
|
*/
|
||||||
|
class Suggestion extends Model
|
||||||
|
{
|
||||||
|
protected $table = 'suggestions';
|
||||||
|
|
||||||
|
protected $primaryKey = 'ID_Suggestion';
|
||||||
|
|
||||||
|
public $timestamps = false;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'ID_Utilisateur',
|
||||||
|
'Type',
|
||||||
|
'Titre',
|
||||||
|
'Description',
|
||||||
|
'GiteaIssueNumber',
|
||||||
|
'CommentairesVus',
|
||||||
|
'DateCreation',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'DateCreation' => 'datetime',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function utilisateur(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Utilisateur::class, 'ID_Utilisateur', 'ID_Utilisateur');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Services;
|
namespace App\Services;
|
||||||
|
|
||||||
|
use Illuminate\Http\Client\PendingRequest;
|
||||||
use Illuminate\Support\Facades\Http;
|
use Illuminate\Support\Facades\Http;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
@@ -10,37 +11,132 @@ class GiteaIssueService
|
|||||||
/**
|
/**
|
||||||
* Crée un ticket (issue) dans le dépôt Gitea configuré.
|
* Crée un ticket (issue) dans le dépôt Gitea configuré.
|
||||||
*
|
*
|
||||||
* @return bool true si le ticket a bien été créé, false sinon.
|
* @return int|null le numéro du ticket créé, ou null en cas d'échec.
|
||||||
*/
|
*/
|
||||||
public function createIssue(string $title, string $body): bool
|
public function createIssue(string $title, string $body): ?int
|
||||||
{
|
{
|
||||||
$url = rtrim((string) config('services.gitea.url'), '/');
|
$client = $this->client();
|
||||||
$repo = trim((string) config('services.gitea.repo'), '/');
|
|
||||||
$token = (string) config('services.gitea.token');
|
|
||||||
|
|
||||||
if ($url === '' || $repo === '' || $token === '') {
|
if ($client === null) {
|
||||||
Log::warning('Gitea non configuré (URL / token / dépôt manquant) : ticket non créé.');
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$response = $client->post($this->repoPath().'/issues', [
|
||||||
|
'title' => $title,
|
||||||
|
'body' => $body,
|
||||||
|
]);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::error('Appel API Gitea impossible : '.$e->getMessage());
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($response->failed()) {
|
||||||
|
Log::error('Création du ticket Gitea échouée', [
|
||||||
|
'status' => $response->status(),
|
||||||
|
'body' => $response->body(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (int) $response->json('number');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récupère l'état d'un ticket (état ouvert/fermé, nombre de commentaires).
|
||||||
|
*
|
||||||
|
* @return array{state: string, comments: int, title: string}|null
|
||||||
|
*/
|
||||||
|
public function getIssue(int $number): ?array
|
||||||
|
{
|
||||||
|
$client = $this->client();
|
||||||
|
|
||||||
|
if ($client === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$response = $client->get($this->repoPath().'/issues/'.$number);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::error('Lecture du ticket Gitea impossible : '.$e->getMessage());
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($response->failed()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'state' => (string) $response->json('state'),
|
||||||
|
'comments' => (int) $response->json('comments'),
|
||||||
|
'title' => (string) $response->json('title'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récupère les commentaires (réponses) d'un ticket.
|
||||||
|
*
|
||||||
|
* @return list<array{auteur: string, corps: string, date: string}>
|
||||||
|
*/
|
||||||
|
public function getComments(int $number): array
|
||||||
|
{
|
||||||
|
$client = $this->client();
|
||||||
|
|
||||||
|
if ($client === null) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$response = $client->get($this->repoPath().'/issues/'.$number.'/comments');
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::error('Lecture des commentaires Gitea impossible : '.$e->getMessage());
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($response->failed()) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$commentaires = [];
|
||||||
|
|
||||||
|
foreach ($response->json() ?? [] as $commentaire) {
|
||||||
|
$commentaires[] = [
|
||||||
|
'auteur' => (string) ($commentaire['user']['username'] ?? $commentaire['user']['login'] ?? 'inconnu'),
|
||||||
|
'corps' => (string) ($commentaire['body'] ?? ''),
|
||||||
|
'date' => (string) ($commentaire['created_at'] ?? ''),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $commentaires;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ajoute un commentaire (réponse de l'utilisateur) sur un ticket.
|
||||||
|
*/
|
||||||
|
public function addComment(int $number, string $body): bool
|
||||||
|
{
|
||||||
|
$client = $this->client();
|
||||||
|
|
||||||
|
if ($client === null) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Gitea attend l'en-tête "Authorization: token <TOKEN>".
|
$response = $client->post($this->repoPath().'/issues/'.$number.'/comments', [
|
||||||
$response = Http::withToken($token, 'token')
|
'body' => $body,
|
||||||
->acceptJson()
|
]);
|
||||||
->timeout(10)
|
|
||||||
->post("{$url}/api/v1/repos/{$repo}/issues", [
|
|
||||||
'title' => $title,
|
|
||||||
'body' => $body,
|
|
||||||
]);
|
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
Log::error('Appel API Gitea impossible : '.$e->getMessage());
|
Log::error('Ajout du commentaire Gitea impossible : '.$e->getMessage());
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($response->failed()) {
|
if ($response->failed()) {
|
||||||
Log::error('Création du ticket Gitea échouée', [
|
Log::error('Ajout du commentaire Gitea échoué', [
|
||||||
'status' => $response->status(),
|
'status' => $response->status(),
|
||||||
'body' => $response->body(),
|
'body' => $response->body(),
|
||||||
]);
|
]);
|
||||||
@@ -50,4 +146,35 @@ class GiteaIssueService
|
|||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client HTTP configuré, ou null si la configuration Gitea est incomplète.
|
||||||
|
*/
|
||||||
|
private function client(): ?PendingRequest
|
||||||
|
{
|
||||||
|
$url = rtrim((string) config('services.gitea.url'), '/');
|
||||||
|
$token = (string) config('services.gitea.token');
|
||||||
|
|
||||||
|
if ($url === '' || $this->repo() === '' || $token === '') {
|
||||||
|
Log::warning('Gitea non configuré (URL / token / dépôt manquant).');
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gitea attend l'en-tête "Authorization: token <TOKEN>".
|
||||||
|
return Http::baseUrl($url)
|
||||||
|
->withToken($token, 'token')
|
||||||
|
->acceptJson()
|
||||||
|
->timeout(10);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function repo(): string
|
||||||
|
{
|
||||||
|
return trim((string) config('services.gitea.repo'), '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function repoPath(): string
|
||||||
|
{
|
||||||
|
return '/api/v1/repos/'.$this->repo();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
// Suivi des suggestions : chaque suggestion est liée au ticket Gitea
|
||||||
|
// créé automatiquement, pour permettre à l'utilisateur de suivre les
|
||||||
|
// réponses de l'équipe depuis son interface web.
|
||||||
|
Schema::create('suggestions', function (Blueprint $table) {
|
||||||
|
$table->id('ID_Suggestion');
|
||||||
|
$table->foreignId('ID_Utilisateur')->constrained('utilisateurs', 'ID_Utilisateur')->onDelete('cascade');
|
||||||
|
$table->string('Type', 50);
|
||||||
|
$table->string('Titre', 150);
|
||||||
|
$table->text('Description');
|
||||||
|
$table->unsignedInteger('GiteaIssueNumber')->nullable();
|
||||||
|
// Nombre de commentaires déjà consultés (pour le badge « nouvelle réponse »)
|
||||||
|
$table->unsignedInteger('CommentairesVus')->default(0);
|
||||||
|
$table->timestamp('DateCreation')->useCurrent();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('suggestions');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -42,7 +42,10 @@
|
|||||||
|
|
||||||
<!-- Menu déroulant -->
|
<!-- Menu déroulant -->
|
||||||
<div class="absolute right-0 mt-0 w-48 bg-white rounded-lg shadow-xl border border-gray-200 opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 z-50">
|
<div class="absolute right-0 mt-0 w-48 bg-white rounded-lg shadow-xl border border-gray-200 opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 z-50">
|
||||||
<a href="{{ route('profile.settings') }}" class="block px-4 py-3 text-sm text-gray-700 hover:bg-gray-50 border-b border-gray-200 rounded-t-lg">
|
<a href="{{ route('suggestions.index') }}" class="block px-4 py-3 text-sm text-gray-700 hover:bg-gray-50 border-b border-gray-200 rounded-t-lg">
|
||||||
|
💬 Mes suggestions
|
||||||
|
</a>
|
||||||
|
<a href="{{ route('profile.settings') }}" class="block px-4 py-3 text-sm text-gray-700 hover:bg-gray-50 border-b border-gray-200">
|
||||||
⚙️ Paramètres du profil
|
⚙️ Paramètres du profil
|
||||||
</a>
|
</a>
|
||||||
<form action="{{ route('logout') }}" method="POST">
|
<form action="{{ route('logout') }}" method="POST">
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
@extends('app')
|
||||||
|
|
||||||
|
@section('title', 'Mes suggestions - (RE)Sources Relationnelles')
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
<section class="container mx-auto px-4 py-12">
|
||||||
|
<div class="max-w-3xl mx-auto">
|
||||||
|
<!-- En-tête -->
|
||||||
|
<div class="flex items-start justify-between mb-8">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-4xl font-bold text-gray-900 mb-2">💬 Mes suggestions</h1>
|
||||||
|
<p class="text-gray-600">
|
||||||
|
Suivez ici la prise en compte de vos suggestions par l'équipe : état du ticket,
|
||||||
|
réponses reçues, et poursuivez la discussion.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<a href="{{ route('suggestions.create') }}" class="shrink-0 inline-flex items-center px-4 py-2.5 text-sm font-bold text-niagara border-2 border-niagara rounded-lg hover:bg-niagara hover:text-white transition-smooth duration-200">
|
||||||
|
💡 Nouvelle suggestion
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Message de succès -->
|
||||||
|
@if (session('success'))
|
||||||
|
<div class="mb-6 p-4 bg-green-50 border border-green-200 rounded-lg">
|
||||||
|
<p class="text-green-700 font-semibold">✅ {{ session('success') }}</p>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@forelse ($suggestions as $suggestion)
|
||||||
|
@php($etat = $etats[$suggestion->ID_Suggestion] ?? null)
|
||||||
|
@php($nouvelles = $etat ? max(0, $etat['comments'] - $suggestion->CommentairesVus) : 0)
|
||||||
|
<a href="{{ route('suggestions.show', $suggestion) }}"
|
||||||
|
class="block bg-white rounded-xl border border-gray-200 p-6 mb-4 hover:border-niagara hover:shadow-md transition-smooth duration-200">
|
||||||
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="flex items-center gap-2 mb-1 flex-wrap">
|
||||||
|
<span class="px-2 py-0.5 text-xs font-bold rounded-full bg-gray-100 text-gray-700">{{ $suggestion->Type }}</span>
|
||||||
|
@if ($etat === null)
|
||||||
|
<span class="px-2 py-0.5 text-xs font-bold rounded-full bg-gray-100 text-gray-500">⚪ État indisponible</span>
|
||||||
|
@elseif ($etat['state'] === 'closed')
|
||||||
|
<span class="px-2 py-0.5 text-xs font-bold rounded-full bg-purple-100 text-purple-700">✔️ Traitée</span>
|
||||||
|
@else
|
||||||
|
<span class="px-2 py-0.5 text-xs font-bold rounded-full bg-green-100 text-green-700">🟢 En cours</span>
|
||||||
|
@endif
|
||||||
|
@if ($nouvelles > 0)
|
||||||
|
<span class="px-2 py-0.5 text-xs font-bold rounded-full bg-candlelight text-gray-900">
|
||||||
|
🔔 {{ $nouvelles }} {{ $nouvelles > 1 ? 'nouvelles réponses' : 'nouvelle réponse' }}
|
||||||
|
</span>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
<p class="font-bold text-gray-900 truncate">{{ $suggestion->Titre }}</p>
|
||||||
|
<p class="text-sm text-gray-500">
|
||||||
|
Envoyée le {{ $suggestion->DateCreation->format('d/m/Y à H\hi') }}
|
||||||
|
@if ($suggestion->GiteaIssueNumber)
|
||||||
|
· ticket n°{{ $suggestion->GiteaIssueNumber }}
|
||||||
|
@endif
|
||||||
|
@if ($etat)
|
||||||
|
· {{ $etat['comments'] }} {{ $etat['comments'] > 1 ? 'réponses' : 'réponse' }}
|
||||||
|
@endif
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span class="text-gray-400 text-xl shrink-0">→</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
@empty
|
||||||
|
<div class="bg-white rounded-xl border border-gray-200 p-10 text-center">
|
||||||
|
<p class="text-gray-600 mb-4">Vous n'avez pas encore envoyé de suggestion.</p>
|
||||||
|
<a href="{{ route('suggestions.create') }}" class="inline-flex items-center px-6 py-3 font-bold bg-candlelight text-gray-900 rounded-lg shadow-md hover:shadow-lg transition-smooth duration-200">
|
||||||
|
💡 Proposer une amélioration
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
@endforelse
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
@endsection
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
@extends('app')
|
||||||
|
|
||||||
|
@section('title', 'Suivi de ma suggestion - (RE)Sources Relationnelles')
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
<section class="container mx-auto px-4 py-12">
|
||||||
|
<div class="max-w-3xl mx-auto">
|
||||||
|
<a href="{{ route('suggestions.index') }}" class="inline-block text-sm text-gray-500 hover:text-niagara mb-4">← Retour à mes suggestions</a>
|
||||||
|
|
||||||
|
<!-- En-tête -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<div class="flex items-center gap-2 mb-2 flex-wrap">
|
||||||
|
<span class="px-2 py-0.5 text-xs font-bold rounded-full bg-gray-100 text-gray-700">{{ $suggestion->Type }}</span>
|
||||||
|
@if ($issue === null)
|
||||||
|
<span class="px-2 py-0.5 text-xs font-bold rounded-full bg-gray-100 text-gray-500">⚪ État indisponible</span>
|
||||||
|
@elseif ($issue['state'] === 'closed')
|
||||||
|
<span class="px-2 py-0.5 text-xs font-bold rounded-full bg-purple-100 text-purple-700">✔️ Traitée</span>
|
||||||
|
@else
|
||||||
|
<span class="px-2 py-0.5 text-xs font-bold rounded-full bg-green-100 text-green-700">🟢 En cours de traitement</span>
|
||||||
|
@endif
|
||||||
|
@if ($suggestion->GiteaIssueNumber)
|
||||||
|
<span class="text-xs text-gray-400">Ticket n°{{ $suggestion->GiteaIssueNumber }}</span>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
<h1 class="text-3xl font-bold text-gray-900">{{ $suggestion->Titre }}</h1>
|
||||||
|
<p class="text-sm text-gray-500 mt-1">Envoyée le {{ $suggestion->DateCreation->format('d/m/Y à H\hi') }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Messages -->
|
||||||
|
@if (session('success'))
|
||||||
|
<div class="mb-6 p-4 bg-green-50 border border-green-200 rounded-lg">
|
||||||
|
<p class="text-green-700 font-semibold">✅ {{ session('success') }}</p>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
@if ($errors->any())
|
||||||
|
<div class="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg">
|
||||||
|
@foreach ($errors->all() as $error)
|
||||||
|
<p class="text-red-700 text-sm">{{ $error }}</p>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<!-- Ma demande -->
|
||||||
|
<div class="bg-white rounded-xl border-2 border-niagara p-6 mb-6">
|
||||||
|
<p class="text-xs font-bold text-niagara uppercase mb-2">Votre demande</p>
|
||||||
|
<p class="text-gray-800 whitespace-pre-line">{{ $suggestion->Description }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Fil de discussion -->
|
||||||
|
<h2 class="text-xl font-bold text-gray-900 mb-4">
|
||||||
|
Réponses {{ count($commentaires) > 0 ? '('.count($commentaires).')' : '' }}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
@forelse ($commentaires as $commentaire)
|
||||||
|
<div class="bg-white rounded-xl border border-gray-200 p-5 mb-3">
|
||||||
|
<div class="flex items-center gap-2 mb-2">
|
||||||
|
<img
|
||||||
|
src="https://ui-avatars.com/api/?name={{ urlencode($commentaire['auteur']) }}&background=06989e&color=fff&size=32"
|
||||||
|
alt=""
|
||||||
|
class="w-8 h-8 rounded-full"
|
||||||
|
>
|
||||||
|
<span class="font-bold text-gray-900 text-sm">{{ $commentaire['auteur'] }}</span>
|
||||||
|
@if ($commentaire['date'])
|
||||||
|
<span class="text-xs text-gray-400">· {{ \Illuminate\Support\Carbon::parse($commentaire['date'])->format('d/m/Y à H\hi') }}</span>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
<p class="text-gray-800 text-sm whitespace-pre-line">{{ $commentaire['corps'] }}</p>
|
||||||
|
</div>
|
||||||
|
@empty
|
||||||
|
<div class="bg-gray-50 rounded-xl border border-dashed border-gray-300 p-6 mb-3 text-center">
|
||||||
|
<p class="text-gray-500 text-sm">
|
||||||
|
Pas encore de réponse de l'équipe — vous serez notifié ici dès qu'une réponse sera publiée.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
@endforelse
|
||||||
|
|
||||||
|
<!-- Répondre -->
|
||||||
|
@if ($suggestion->GiteaIssueNumber && ($issue['state'] ?? 'open') !== 'closed')
|
||||||
|
<form action="{{ route('suggestions.repondre', $suggestion) }}" method="POST" class="bg-white rounded-xl border border-gray-200 p-6 mt-6">
|
||||||
|
@csrf
|
||||||
|
<label for="message" class="block text-sm font-bold mb-2 text-gray-700">Répondre sur le ticket</label>
|
||||||
|
<textarea
|
||||||
|
id="message"
|
||||||
|
name="message"
|
||||||
|
required
|
||||||
|
minlength="2"
|
||||||
|
maxlength="2000"
|
||||||
|
rows="4"
|
||||||
|
placeholder="Apportez une précision, répondez à l'équipe…"
|
||||||
|
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-niagara outline-none @error('message') border-red-500 @enderror">{{ old('message') }}</textarea>
|
||||||
|
@error('message')
|
||||||
|
<p class="text-red-600 text-xs mt-1">{{ $message }}</p>
|
||||||
|
@enderror
|
||||||
|
<button type="submit" class="mt-3 px-6 py-2.5 font-bold gradient-niagara text-white rounded-lg hover:shadow-lg transition-smooth duration-200">
|
||||||
|
📨 Envoyer ma réponse
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
@elseif (($issue['state'] ?? null) === 'closed')
|
||||||
|
<p class="text-sm text-gray-500 mt-6 text-center">
|
||||||
|
Cette suggestion a été traitée : le ticket est clôturé. Merci pour votre contribution !
|
||||||
|
</p>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
@endsection
|
||||||
+6
-1
@@ -54,7 +54,12 @@ Route::middleware('auth')->group(function () {
|
|||||||
Route::put('/profile/update', [ProfileController::class, 'update'])->name('profile.update');
|
Route::put('/profile/update', [ProfileController::class, 'update'])->name('profile.update');
|
||||||
Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy');
|
Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy');
|
||||||
|
|
||||||
// Suggestions des utilisateurs (créent un ticket dans Gitea)
|
// Suggestions des utilisateurs (créent un ticket dans Gitea) + suivi
|
||||||
|
Route::get('/suggestions', [SuggestionController::class, 'index'])->name('suggestions.index');
|
||||||
Route::get('/suggestions/nouveau', [SuggestionController::class, 'create'])->name('suggestions.create');
|
Route::get('/suggestions/nouveau', [SuggestionController::class, 'create'])->name('suggestions.create');
|
||||||
Route::post('/suggestions', [SuggestionController::class, 'store'])->name('suggestions.store');
|
Route::post('/suggestions', [SuggestionController::class, 'store'])->name('suggestions.store');
|
||||||
|
Route::get('/suggestions/{suggestion}', [SuggestionController::class, 'show'])
|
||||||
|
->whereNumber('suggestion')->name('suggestions.show');
|
||||||
|
Route::post('/suggestions/{suggestion}/repondre', [SuggestionController::class, 'repondre'])
|
||||||
|
->whereNumber('suggestion')->name('suggestions.repondre');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace Tests\Feature;
|
namespace Tests\Feature;
|
||||||
|
|
||||||
use App\Models\Role;
|
use App\Models\Role;
|
||||||
|
use App\Models\Suggestion;
|
||||||
use App\Models\Utilisateur;
|
use App\Models\Utilisateur;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
@@ -13,19 +14,30 @@ class SuggestionTest extends TestCase
|
|||||||
{
|
{
|
||||||
use RefreshDatabase;
|
use RefreshDatabase;
|
||||||
|
|
||||||
private function utilisateur(): Utilisateur
|
private function utilisateur(string $pseudo = 'JeanTest', string $email = 'jean.test@example.com'): Utilisateur
|
||||||
{
|
{
|
||||||
$role = Role::create(['Nom' => 'Utilisateur']);
|
$role = Role::firstOrCreate(['Nom' => 'Utilisateur']);
|
||||||
|
|
||||||
return Utilisateur::create([
|
return Utilisateur::create([
|
||||||
'Pseudo' => 'JeanTest',
|
'Pseudo' => $pseudo,
|
||||||
'Email' => 'jean.test@example.com',
|
'Email' => $email,
|
||||||
'MotDePasse' => Hash::make('motdepasse123'),
|
'MotDePasse' => Hash::make('motdepasse123'),
|
||||||
'ID_Role' => $role->ID_Role,
|
'ID_Role' => $role->ID_Role,
|
||||||
'Statut' => 'Actif',
|
'Statut' => 'Actif',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function suggestion(Utilisateur $utilisateur, ?int $numero = 42): Suggestion
|
||||||
|
{
|
||||||
|
return Suggestion::create([
|
||||||
|
'ID_Utilisateur' => $utilisateur->ID_Utilisateur,
|
||||||
|
'Type' => 'Correction',
|
||||||
|
'Titre' => 'Faute dans une ressource',
|
||||||
|
'Description' => 'Il y a une faute à corriger sur la page des ressources.',
|
||||||
|
'GiteaIssueNumber' => $numero,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
private function configurerGitea(): void
|
private function configurerGitea(): void
|
||||||
{
|
{
|
||||||
config([
|
config([
|
||||||
@@ -38,22 +50,26 @@ class SuggestionTest extends TestCase
|
|||||||
public function test_le_formulaire_de_suggestion_exige_une_authentification(): void
|
public function test_le_formulaire_de_suggestion_exige_une_authentification(): void
|
||||||
{
|
{
|
||||||
$this->get('/suggestions/nouveau')->assertRedirect('/login');
|
$this->get('/suggestions/nouveau')->assertRedirect('/login');
|
||||||
|
$this->get('/suggestions')->assertRedirect('/login');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_une_suggestion_valide_cree_un_ticket_gitea(): void
|
public function test_une_suggestion_valide_cree_un_ticket_gitea_et_est_conservee(): void
|
||||||
{
|
{
|
||||||
$this->configurerGitea();
|
$this->configurerGitea();
|
||||||
Http::fake([
|
Http::fake([
|
||||||
'*/api/v1/repos/*/issues' => Http::response(['number' => 42], 201),
|
'*/api/v1/repos/*/issues' => Http::response(['number' => 42], 201),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$response = $this->actingAs($this->utilisateur())->post('/suggestions', [
|
$utilisateur = $this->utilisateur();
|
||||||
|
|
||||||
|
$response = $this->actingAs($utilisateur)->post('/suggestions', [
|
||||||
'type' => 'Correction',
|
'type' => 'Correction',
|
||||||
'titre' => 'Faute dans une ressource',
|
'titre' => 'Faute dans une ressource',
|
||||||
'description' => 'Il y a une faute à corriger sur la page des ressources.',
|
'description' => 'Il y a une faute à corriger sur la page des ressources.',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$response->assertRedirect(route('suggestions.create'));
|
// L'utilisateur arrive sur la page de suivi de ses suggestions
|
||||||
|
$response->assertRedirect(route('suggestions.index'));
|
||||||
$response->assertSessionHas('success');
|
$response->assertSessionHas('success');
|
||||||
|
|
||||||
Http::assertSent(function ($request) {
|
Http::assertSent(function ($request) {
|
||||||
@@ -61,6 +77,118 @@ class SuggestionTest extends TestCase
|
|||||||
&& $request['title'] === '[Correction] Faute dans une ressource'
|
&& $request['title'] === '[Correction] Faute dans une ressource'
|
||||||
&& str_contains($request['body'], 'JeanTest');
|
&& str_contains($request['body'], 'JeanTest');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// La suggestion est liée au ticket pour permettre le suivi
|
||||||
|
$this->assertDatabaseHas('suggestions', [
|
||||||
|
'ID_Utilisateur' => $utilisateur->ID_Utilisateur,
|
||||||
|
'Titre' => 'Faute dans une ressource',
|
||||||
|
'GiteaIssueNumber' => 42,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_la_liste_affiche_l_etat_du_ticket_et_les_nouvelles_reponses(): void
|
||||||
|
{
|
||||||
|
$this->configurerGitea();
|
||||||
|
Http::fake([
|
||||||
|
'gitea.test/api/v1/repos/*/issues/42' => Http::response([
|
||||||
|
'number' => 42, 'state' => 'open', 'comments' => 2, 'title' => '[Correction] Faute',
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$utilisateur = $this->utilisateur();
|
||||||
|
$this->suggestion($utilisateur);
|
||||||
|
|
||||||
|
$response = $this->actingAs($utilisateur)->get('/suggestions');
|
||||||
|
|
||||||
|
$response->assertOk()
|
||||||
|
->assertSee('Faute dans une ressource')
|
||||||
|
->assertSee('En cours')
|
||||||
|
->assertSee('2 nouvelles réponses');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_la_liste_ne_montre_pas_les_suggestions_des_autres(): void
|
||||||
|
{
|
||||||
|
$this->configurerGitea();
|
||||||
|
Http::fake();
|
||||||
|
|
||||||
|
$autre = $this->utilisateur('AutreUser', 'autre@example.com');
|
||||||
|
$this->suggestion($autre);
|
||||||
|
|
||||||
|
$moi = $this->utilisateur('Moi', 'moi@example.com');
|
||||||
|
|
||||||
|
$this->actingAs($moi)->get('/suggestions')
|
||||||
|
->assertOk()
|
||||||
|
->assertDontSee('Faute dans une ressource');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_le_detail_affiche_les_reponses_du_ticket_et_les_marque_comme_vues(): void
|
||||||
|
{
|
||||||
|
$this->configurerGitea();
|
||||||
|
Http::fake([
|
||||||
|
'gitea.test/api/v1/repos/*/issues/42/comments' => Http::response([
|
||||||
|
[
|
||||||
|
'user' => ['username' => 'sam-depardieu'],
|
||||||
|
'body' => 'Merci, correction prévue dans la v1.1 !',
|
||||||
|
'created_at' => '2026-07-08T10:00:00Z',
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
'gitea.test/api/v1/repos/*/issues/42' => Http::response([
|
||||||
|
'number' => 42, 'state' => 'open', 'comments' => 1, 'title' => '[Correction] Faute',
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$utilisateur = $this->utilisateur();
|
||||||
|
$suggestion = $this->suggestion($utilisateur);
|
||||||
|
|
||||||
|
$response = $this->actingAs($utilisateur)->get('/suggestions/'.$suggestion->ID_Suggestion);
|
||||||
|
|
||||||
|
$response->assertOk()
|
||||||
|
->assertSee('sam-depardieu')
|
||||||
|
->assertSee('Merci, correction prévue dans la v1.1 !');
|
||||||
|
|
||||||
|
// Les réponses consultées ne comptent plus comme « nouvelles »
|
||||||
|
$this->assertSame(1, $suggestion->fresh()->CommentairesVus);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_le_detail_d_une_suggestion_d_un_autre_est_interdit(): void
|
||||||
|
{
|
||||||
|
$this->configurerGitea();
|
||||||
|
Http::fake();
|
||||||
|
|
||||||
|
$autre = $this->utilisateur('AutreUser', 'autre@example.com');
|
||||||
|
$suggestion = $this->suggestion($autre);
|
||||||
|
|
||||||
|
$moi = $this->utilisateur('Moi', 'moi@example.com');
|
||||||
|
|
||||||
|
$this->actingAs($moi)->get('/suggestions/'.$suggestion->ID_Suggestion)
|
||||||
|
->assertForbidden();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_l_utilisateur_peut_repondre_sur_le_ticket(): void
|
||||||
|
{
|
||||||
|
$this->configurerGitea();
|
||||||
|
Http::fake([
|
||||||
|
'gitea.test/api/v1/repos/*/issues/42/comments' => Http::response(['id' => 7], 201),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$utilisateur = $this->utilisateur();
|
||||||
|
$suggestion = $this->suggestion($utilisateur);
|
||||||
|
|
||||||
|
$response = $this->actingAs($utilisateur)
|
||||||
|
->post('/suggestions/'.$suggestion->ID_Suggestion.'/repondre', [
|
||||||
|
'message' => 'Précision : la faute est dans le deuxième paragraphe.',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertRedirect(route('suggestions.show', $suggestion));
|
||||||
|
$response->assertSessionHas('success');
|
||||||
|
|
||||||
|
// Le commentaire est publié sur le ticket Gitea, signé du pseudo
|
||||||
|
Http::assertSent(function ($request) {
|
||||||
|
return $request->method() === 'POST'
|
||||||
|
&& str_contains($request->url(), '/issues/42/comments')
|
||||||
|
&& str_contains($request['body'], 'JeanTest')
|
||||||
|
&& str_contains($request['body'], 'deuxième paragraphe');
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_une_suggestion_invalide_est_rejetee(): void
|
public function test_une_suggestion_invalide_est_rejetee(): void
|
||||||
@@ -91,5 +219,8 @@ class SuggestionTest extends TestCase
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$response->assertSessionHasErrors('gitea');
|
$response->assertSessionHasErrors('gitea');
|
||||||
|
|
||||||
|
// Rien n'est conservé si le ticket n'a pas pu être créé
|
||||||
|
$this->assertDatabaseCount('suggestions', 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user