feat: formulaire de suggestions créant un ticket Gitea
Les utilisateurs connectés peuvent proposer une modification / un ajout / une correction via un formulaire. La soumission crée automatiquement une issue dans le dépôt Gitea via son API (GiteaIssueService), avec le titre préfixé par le type et l'auteur dans le corps. - config/services.php + .env.example : GITEA_URL/TOKEN/REPO (token secret) - SuggestionController (create/store) + validation - vue suggestions/create + lien dans le header (utilisateurs connectés) - tests: auth requise, création OK, validation, échec API Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -69,4 +69,11 @@ AWS_DEFAULT_REGION=us-east-1
|
|||||||
AWS_BUCKET=
|
AWS_BUCKET=
|
||||||
AWS_USE_PATH_STYLE_ENDPOINT=false
|
AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||||
|
|
||||||
|
# --- Gitea (tickets de suggestion créés depuis le site) ---
|
||||||
|
# Token d'accès Gitea avec le scope "write:issue" (Paramètres > Applications > Générer un token).
|
||||||
|
# À garder secret (ne jamais committer la vraie valeur).
|
||||||
|
GITEA_URL=https://gitea.sam-coffre.duckdns.org
|
||||||
|
GITEA_TOKEN=
|
||||||
|
GITEA_REPO=Groupe1/Ressources-relationnel
|
||||||
|
|
||||||
VITE_APP_NAME="${APP_NAME}"
|
VITE_APP_NAME="${APP_NAME}"
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\Utilisateur;
|
||||||
|
use App\Services\GiteaIssueService;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class SuggestionController extends Controller
|
||||||
|
{
|
||||||
|
// Afficher le formulaire de suggestion
|
||||||
|
public function create()
|
||||||
|
{
|
||||||
|
return view('suggestions.create');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Traiter la suggestion et créer un ticket dans Gitea
|
||||||
|
public function store(Request $request, GiteaIssueService $gitea)
|
||||||
|
{
|
||||||
|
$data = $request->validate([
|
||||||
|
'type' => ['required', Rule::in(['Modification', 'Ajout', 'Correction'])],
|
||||||
|
'titre' => ['required', 'string', 'min:5', 'max:120'],
|
||||||
|
'description' => ['required', 'string', 'min:10', 'max:5000'],
|
||||||
|
], [
|
||||||
|
'type.required' => 'Veuillez choisir un type de demande.',
|
||||||
|
'type.in' => 'Type de demande invalide.',
|
||||||
|
'titre.required' => 'Le titre est requis.',
|
||||||
|
'titre.min' => 'Le titre doit contenir au moins 5 caractères.',
|
||||||
|
'titre.max' => 'Le titre ne peut pas dépasser 120 caractères.',
|
||||||
|
'description.required' => 'La description est requise.',
|
||||||
|
'description.min' => 'Merci de détailler un peu plus votre suggestion (10 caractères minimum).',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** @var Utilisateur $user */
|
||||||
|
$user = Auth::user();
|
||||||
|
$auteur = $user->Pseudo ?? 'Utilisateur';
|
||||||
|
|
||||||
|
$title = "[{$data['type']}] {$data['titre']}";
|
||||||
|
|
||||||
|
$body = "**Type de demande :** {$data['type']}\n"
|
||||||
|
."**Proposé par :** {$auteur} (via le site (RE)Sources Relationnelles)\n\n"
|
||||||
|
."### Description\n"
|
||||||
|
.$data['description']."\n\n"
|
||||||
|
."---\n"
|
||||||
|
.'_Ticket créé automatiquement depuis le formulaire de suggestions du site._';
|
||||||
|
|
||||||
|
if ($gitea->createIssue($title, $body)) {
|
||||||
|
return redirect()->route('suggestions.create')
|
||||||
|
->with('success', 'Merci ! Votre suggestion a bien été transmise à l\'équipe.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return back()->withInput()->withErrors([
|
||||||
|
'gitea' => "L'envoi de votre suggestion a échoué. Merci de réessayer plus tard.",
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
class GiteaIssueService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Crée un ticket (issue) dans le dépôt Gitea configuré.
|
||||||
|
*
|
||||||
|
* @return bool true si le ticket a bien été créé, false sinon.
|
||||||
|
*/
|
||||||
|
public function createIssue(string $title, string $body): bool
|
||||||
|
{
|
||||||
|
$url = rtrim((string) config('services.gitea.url'), '/');
|
||||||
|
$repo = trim((string) config('services.gitea.repo'), '/');
|
||||||
|
$token = (string) config('services.gitea.token');
|
||||||
|
|
||||||
|
if ($url === '' || $repo === '' || $token === '') {
|
||||||
|
Log::warning('Gitea non configuré (URL / token / dépôt manquant) : ticket non créé.');
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Gitea attend l'en-tête "Authorization: token <TOKEN>".
|
||||||
|
$response = Http::withToken($token, 'token')
|
||||||
|
->acceptJson()
|
||||||
|
->timeout(10)
|
||||||
|
->post("{$url}/api/v1/repos/{$repo}/issues", [
|
||||||
|
'title' => $title,
|
||||||
|
'body' => $body,
|
||||||
|
]);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::error('Appel API Gitea impossible : '.$e->getMessage());
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($response->failed()) {
|
||||||
|
Log::error('Création du ticket Gitea échouée', [
|
||||||
|
'status' => $response->status(),
|
||||||
|
'body' => $response->body(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,4 +35,12 @@ return [
|
|||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|
||||||
|
// Gitea : création automatique de tickets (issues) depuis le formulaire
|
||||||
|
// de suggestions du site. Le token doit avoir le scope "write:issue".
|
||||||
|
'gitea' => [
|
||||||
|
'url' => env('GITEA_URL', 'https://gitea.sam-coffre.duckdns.org'),
|
||||||
|
'token' => env('GITEA_TOKEN'),
|
||||||
|
'repo' => env('GITEA_REPO', 'Groupe1/Ressources-relationnel'),
|
||||||
|
],
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -24,6 +24,9 @@
|
|||||||
|
|
||||||
<nav class="flex items-center gap-2 whitespace-nowrap">
|
<nav class="flex items-center gap-2 whitespace-nowrap">
|
||||||
@auth
|
@auth
|
||||||
|
<a href="{{ route('suggestions.create') }}" class="hidden sm: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">
|
||||||
|
💡 Proposer une amélioration
|
||||||
|
</a>
|
||||||
<div class="relative group">
|
<div class="relative group">
|
||||||
<button class="flex items-center gap-2 p-2 rounded-lg hover:bg-gray-100 transition-smooth duration-200">
|
<button class="flex items-center gap-2 p-2 rounded-lg hover:bg-gray-100 transition-smooth duration-200">
|
||||||
<img
|
<img
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
@extends('app')
|
||||||
|
|
||||||
|
@section('title', 'Proposer une amélioration - (RE)Sources Relationnelles')
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
<section class="container mx-auto px-4 py-12">
|
||||||
|
<div class="max-w-2xl mx-auto">
|
||||||
|
<!-- En-tête -->
|
||||||
|
<div class="mb-8">
|
||||||
|
<h1 class="text-4xl font-bold text-gray-900 mb-2">💡 Proposer une amélioration</h1>
|
||||||
|
<p class="text-gray-600">
|
||||||
|
Une idée, une correction, un ajout ? Votre suggestion est directement transmise à
|
||||||
|
l'équipe sous forme de ticket. Merci de votre contribution !
|
||||||
|
</p>
|
||||||
|
</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
|
||||||
|
|
||||||
|
<!-- Erreurs -->
|
||||||
|
@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
|
||||||
|
|
||||||
|
<div class="bg-white rounded-xl border border-gray-200 p-8">
|
||||||
|
<form action="{{ route('suggestions.store') }}" method="POST" class="space-y-6">
|
||||||
|
@csrf
|
||||||
|
|
||||||
|
<!-- Type de demande -->
|
||||||
|
<div>
|
||||||
|
<label for="type" class="block text-sm font-bold mb-2 text-gray-700">Type de demande</label>
|
||||||
|
<select
|
||||||
|
id="type"
|
||||||
|
name="type"
|
||||||
|
required
|
||||||
|
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-niagara outline-none @error('type') border-red-500 @enderror">
|
||||||
|
@php($typeChoisi = old('type'))
|
||||||
|
<option value="Modification" @selected($typeChoisi === 'Modification')>✏️ Modification d'un contenu existant</option>
|
||||||
|
<option value="Ajout" @selected($typeChoisi === 'Ajout')>➕ Ajout d'un nouveau contenu / fonctionnalité</option>
|
||||||
|
<option value="Correction" @selected($typeChoisi === 'Correction')>🐛 Correction d'une erreur / d'un bug</option>
|
||||||
|
</select>
|
||||||
|
@error('type')
|
||||||
|
<p class="text-red-600 text-xs mt-1">{{ $message }}</p>
|
||||||
|
@enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Titre -->
|
||||||
|
<div>
|
||||||
|
<label for="titre" class="block text-sm font-bold mb-2 text-gray-700">Titre</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="titre"
|
||||||
|
name="titre"
|
||||||
|
required
|
||||||
|
minlength="5"
|
||||||
|
maxlength="120"
|
||||||
|
value="{{ old('titre') }}"
|
||||||
|
placeholder="Résumez votre suggestion en une phrase"
|
||||||
|
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-niagara outline-none @error('titre') border-red-500 @enderror">
|
||||||
|
@error('titre')
|
||||||
|
<p class="text-red-600 text-xs mt-1">{{ $message }}</p>
|
||||||
|
@enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Description -->
|
||||||
|
<div>
|
||||||
|
<label for="description" class="block text-sm font-bold mb-2 text-gray-700">Description détaillée</label>
|
||||||
|
<textarea
|
||||||
|
id="description"
|
||||||
|
name="description"
|
||||||
|
required
|
||||||
|
minlength="10"
|
||||||
|
maxlength="5000"
|
||||||
|
rows="8"
|
||||||
|
placeholder="Décrivez précisément votre proposition : quoi, où, pourquoi…"
|
||||||
|
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-niagara outline-none @error('description') border-red-500 @enderror">{{ old('description') }}</textarea>
|
||||||
|
@error('description')
|
||||||
|
<p class="text-red-600 text-xs mt-1">{{ $message }}</p>
|
||||||
|
@enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Boutons -->
|
||||||
|
<div class="flex gap-4 pt-2">
|
||||||
|
<button type="submit" class="px-8 py-3 gradient-niagara text-white font-bold rounded-lg hover:shadow-lg transition-smooth duration-200">
|
||||||
|
📨 Envoyer ma suggestion
|
||||||
|
</button>
|
||||||
|
<a href="{{ route('home') }}" class="px-8 py-3 bg-gray-200 text-gray-900 font-bold rounded-lg hover:bg-gray-300 transition-smooth duration-200">
|
||||||
|
Annuler
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-xs text-gray-500 mt-4">
|
||||||
|
ℹ️ Votre suggestion sera examinée par l'équipe avant toute prise en compte. Merci de rester courtois et constructif.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
@endsection
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
use App\Http\Controllers\LoginController;
|
use App\Http\Controllers\LoginController;
|
||||||
use App\Http\Controllers\ProfileController;
|
use App\Http\Controllers\ProfileController;
|
||||||
|
use App\Http\Controllers\SuggestionController;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
@@ -48,4 +49,8 @@ Route::middleware('auth')->group(function () {
|
|||||||
Route::get('/profile/settings', [ProfileController::class, 'settings'])->name('profile.settings');
|
Route::get('/profile/settings', [ProfileController::class, 'settings'])->name('profile.settings');
|
||||||
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)
|
||||||
|
Route::get('/suggestions/nouveau', [SuggestionController::class, 'create'])->name('suggestions.create');
|
||||||
|
Route::post('/suggestions', [SuggestionController::class, 'store'])->name('suggestions.store');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Models\Role;
|
||||||
|
use App\Models\Utilisateur;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class SuggestionTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
private function utilisateur(): Utilisateur
|
||||||
|
{
|
||||||
|
$role = Role::create(['Nom' => 'Utilisateur']);
|
||||||
|
|
||||||
|
return Utilisateur::create([
|
||||||
|
'Pseudo' => 'JeanTest',
|
||||||
|
'Email' => 'jean.test@example.com',
|
||||||
|
'MotDePasse' => Hash::make('motdepasse123'),
|
||||||
|
'ID_Role' => $role->ID_Role,
|
||||||
|
'Statut' => 'Actif',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function configurerGitea(): void
|
||||||
|
{
|
||||||
|
config([
|
||||||
|
'services.gitea.url' => 'https://gitea.test',
|
||||||
|
'services.gitea.token' => 'fake-token',
|
||||||
|
'services.gitea.repo' => 'Groupe1/Ressources-relationnel',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_le_formulaire_de_suggestion_exige_une_authentification(): void
|
||||||
|
{
|
||||||
|
$this->get('/suggestions/nouveau')->assertRedirect('/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_une_suggestion_valide_cree_un_ticket_gitea(): void
|
||||||
|
{
|
||||||
|
$this->configurerGitea();
|
||||||
|
Http::fake([
|
||||||
|
'*/api/v1/repos/*/issues' => Http::response(['number' => 42], 201),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->actingAs($this->utilisateur())->post('/suggestions', [
|
||||||
|
'type' => 'Correction',
|
||||||
|
'titre' => 'Faute dans une ressource',
|
||||||
|
'description' => 'Il y a une faute à corriger sur la page des ressources.',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertRedirect(route('suggestions.create'));
|
||||||
|
$response->assertSessionHas('success');
|
||||||
|
|
||||||
|
Http::assertSent(function ($request) {
|
||||||
|
return str_contains($request->url(), '/api/v1/repos/Groupe1/Ressources-relationnel/issues')
|
||||||
|
&& $request['title'] === '[Correction] Faute dans une ressource'
|
||||||
|
&& str_contains($request['body'], 'JeanTest');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_une_suggestion_invalide_est_rejetee(): void
|
||||||
|
{
|
||||||
|
Http::fake();
|
||||||
|
|
||||||
|
$response = $this->actingAs($this->utilisateur())->post('/suggestions', [
|
||||||
|
'type' => 'Inconnu',
|
||||||
|
'titre' => 'x',
|
||||||
|
'description' => 'court',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertSessionHasErrors(['type', 'titre', 'description']);
|
||||||
|
Http::assertNothingSent();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_un_echec_gitea_affiche_une_erreur(): void
|
||||||
|
{
|
||||||
|
$this->configurerGitea();
|
||||||
|
Http::fake([
|
||||||
|
'*/api/v1/repos/*/issues' => Http::response(['message' => 'nope'], 500),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->actingAs($this->utilisateur())->post('/suggestions', [
|
||||||
|
'type' => 'Ajout',
|
||||||
|
'titre' => 'Nouvelle fonctionnalité utile',
|
||||||
|
'description' => 'Ce serait bien d\'ajouter un filtre par thème.',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertSessionHasErrors('gitea');
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user