feat(tickets): suivi bidirectionnel des tickets utilisateur via Gitea
CI - Intégration continue / quality-and-tests (pull_request) Successful in 3m27s
CI - Intégration continue / quality-and-tests (pull_request) Successful in 3m27s
Le formulaire 'fire-and-forget' devient un vrai systeme de suivi : - table locale tickets (user <-> numero d'issue Gitea) - liste 'Mes tickets' avec etat (En cours / Resolu) et jalon - page de suivi : etat, labels, jalon, fil de discussion (commentaires) - reponse depuis le web (ajoute un commentaire sur l'issue Gitea) - controle d'acces : un utilisateur ne voit que ses propres tickets - GiteaIssueService etendu (getIssue, getComments, addComment) - remplace l'ancien SuggestionController ; 5 tests de bout en bout Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,59 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
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 / signalement
|
||||
public function create()
|
||||
{
|
||||
return view('suggestions.create');
|
||||
}
|
||||
|
||||
// Traiter la demande 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 demande (10 caractères minimum).',
|
||||
]);
|
||||
|
||||
/** @var User $user */
|
||||
$user = Auth::user();
|
||||
$auteur = $user->name ?? 'Utilisateur';
|
||||
|
||||
$title = "[{$data['type']}] {$data['titre']}";
|
||||
|
||||
$body = "**Type de demande :** {$data['type']}\n"
|
||||
."**Proposé par :** {$auteur} (via le site CESIZen)\n\n"
|
||||
."### Description\n"
|
||||
.$data['description']."\n\n"
|
||||
."---\n"
|
||||
.'_Ticket créé automatiquement depuis le formulaire de suggestions du site CESIZen._';
|
||||
|
||||
if ($gitea->createIssue($title, $body)) {
|
||||
return redirect()->route('suggestions.create')
|
||||
->with('success', 'Merci ! Votre demande a bien été transmise à l\'équipe.');
|
||||
}
|
||||
|
||||
return back()->withInput()->withErrors([
|
||||
'gitea' => "L'envoi de votre demande a échoué. Merci de réessayer plus tard.",
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Ticket;
|
||||
use App\Models\User;
|
||||
use App\Services\GiteaIssueService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class TicketController extends Controller
|
||||
{
|
||||
public function __construct(private GiteaIssueService $gitea) {}
|
||||
|
||||
/**
|
||||
* Liste des tickets de l'utilisateur, avec leur état courant récupéré
|
||||
* depuis Gitea (ouvert / résolu, jalon).
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = Auth::user();
|
||||
|
||||
$tickets = $user->tickets()->latest()->get()->map(function (Ticket $ticket) {
|
||||
$issue = $this->gitea->getIssue($ticket->gitea_number);
|
||||
$ticket->setAttribute('state', $issue['state'] ?? null);
|
||||
$ticket->setAttribute('milestone', $issue['milestone']['title'] ?? null);
|
||||
|
||||
return $ticket;
|
||||
});
|
||||
|
||||
return view('tickets.index', compact('tickets'));
|
||||
}
|
||||
|
||||
// Formulaire de création
|
||||
public function create()
|
||||
{
|
||||
return view('tickets.create');
|
||||
}
|
||||
|
||||
// Création d'un ticket -> issue Gitea + enregistrement local
|
||||
public function store(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'type' => ['required', Rule::in(['Modification', 'Ajout', 'Correction'])],
|
||||
'titre' => ['required', 'string', 'min:5', 'max:120'],
|
||||
'description' => ['required', 'string', 'min:10', 'max:5000'],
|
||||
]);
|
||||
|
||||
/** @var User $user */
|
||||
$user = Auth::user();
|
||||
$auteur = $user->name ?? 'Utilisateur';
|
||||
|
||||
$title = "[{$data['type']}] {$data['titre']}";
|
||||
$body = "**Type de demande :** {$data['type']}\n"
|
||||
."**Proposé par :** {$auteur} (via le site CESIZen)\n\n"
|
||||
."### Description\n"
|
||||
.$data['description']."\n\n"
|
||||
."---\n"
|
||||
.'_Ticket créé automatiquement depuis CESIZen._';
|
||||
|
||||
$issue = $this->gitea->createIssue($title, $body);
|
||||
|
||||
if (! $issue || ! isset($issue['number'])) {
|
||||
return back()->withInput()->withErrors([
|
||||
'gitea' => "L'envoi de votre demande a échoué. Merci de réessayer plus tard.",
|
||||
]);
|
||||
}
|
||||
|
||||
// On mémorise le lien utilisateur <-> issue pour le suivi.
|
||||
$user->tickets()->create([
|
||||
'gitea_number' => (int) $issue['number'],
|
||||
'title' => $data['titre'],
|
||||
'type' => $data['type'],
|
||||
]);
|
||||
|
||||
return redirect()->route('tickets.show', $issue['number'])
|
||||
->with('success', 'Votre demande a bien été transmise à l\'équipe.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Détail d'un ticket : état, jalon, labels et fil de discussion.
|
||||
*/
|
||||
public function show(int $number)
|
||||
{
|
||||
$ticket = $this->findOwnedTicket($number);
|
||||
|
||||
$issue = $this->gitea->getIssue($number);
|
||||
$comments = $this->gitea->getComments($number);
|
||||
|
||||
return view('tickets.show', compact('ticket', 'issue', 'comments'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Réponse de l'utilisateur : ajoute un commentaire sur l'issue Gitea.
|
||||
*/
|
||||
public function reply(Request $request, int $number)
|
||||
{
|
||||
$this->findOwnedTicket($number);
|
||||
|
||||
$data = $request->validate([
|
||||
'message' => ['required', 'string', 'min:2', 'max:5000'],
|
||||
]);
|
||||
|
||||
/** @var User $user */
|
||||
$user = Auth::user();
|
||||
$auteur = $user->name ?? 'Utilisateur';
|
||||
$body = "**{$auteur}** (via CESIZen) :\n\n".$data['message'];
|
||||
|
||||
if (! $this->gitea->addComment($number, $body)) {
|
||||
return back()->withErrors(['gitea' => "L'envoi de votre réponse a échoué."]);
|
||||
}
|
||||
|
||||
return redirect()->route('tickets.show', $number)
|
||||
->with('success', 'Votre réponse a été ajoutée au ticket.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère le ticket s'il appartient bien à l'utilisateur connecté,
|
||||
* sinon renvoie une 404 (empêche de consulter les tickets d'autrui).
|
||||
*/
|
||||
private function findOwnedTicket(int $number): Ticket
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = Auth::user();
|
||||
|
||||
return $user->tickets()->where('gitea_number', $number)->firstOrFail();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property int $user_id
|
||||
* @property int $gitea_number
|
||||
* @property string $title
|
||||
* @property string $type
|
||||
*/
|
||||
class Ticket extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'gitea_number',
|
||||
'title',
|
||||
'type',
|
||||
];
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,14 @@ class User extends Authenticatable implements FilamentUser
|
||||
return $this->hasMany(EmotionRecord::class, 'user_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HasMany<Ticket, $this>
|
||||
*/
|
||||
public function tickets(): HasMany
|
||||
{
|
||||
return $this->hasMany(Ticket::class);
|
||||
}
|
||||
|
||||
public function favoritedActivities(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(RelaxationActivity::class, 'favorites', 'id_user', 'id_activite');
|
||||
|
||||
@@ -2,52 +2,132 @@
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Http\Client\PendingRequest;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Petit client de l'API Gitea (issues) utilisé par le système de tickets :
|
||||
* création, consultation, commentaires.
|
||||
*/
|
||||
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.
|
||||
* Prépare un client HTTP authentifié, ou null si Gitea n'est pas configuré.
|
||||
*/
|
||||
public function createIssue(string $title, string $body): bool
|
||||
private function client(): ?PendingRequest
|
||||
{
|
||||
$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éé.');
|
||||
Log::warning('Gitea non configuré (URL / token / dépôt manquant).');
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Gitea attend l'en-tête "Authorization: token <TOKEN>".
|
||||
return Http::withToken($token, 'token')
|
||||
->acceptJson()
|
||||
->timeout(10)
|
||||
->baseUrl("{$url}/api/v1/repos/{$repo}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Crée un ticket (issue). Retourne les données de l'issue créée
|
||||
* (dont "number") ou null en cas d'échec.
|
||||
*
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public function createIssue(string $title, string $body): ?array
|
||||
{
|
||||
$client = $this->client();
|
||||
if (! $client) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$response = $client->post('/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()]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return (array) $response->json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère une issue (état, labels, jalon…).
|
||||
*
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public function getIssue(int $number): ?array
|
||||
{
|
||||
$client = $this->client();
|
||||
if (! $client) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$response = $client->get("/issues/{$number}");
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Lecture du ticket Gitea impossible : '.$e->getMessage());
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return $response->successful() ? (array) $response->json() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère les commentaires d'une issue (fil de discussion).
|
||||
*
|
||||
* @return array<int, mixed>
|
||||
*/
|
||||
public function getComments(int $number): array
|
||||
{
|
||||
$client = $this->client();
|
||||
if (! $client) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
$response = $client->get("/issues/{$number}/comments");
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Lecture des commentaires Gitea impossible : '.$e->getMessage());
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
return $response->successful() ? (array) $response->json() : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Ajoute un commentaire (réponse) à une issue.
|
||||
*/
|
||||
public function addComment(int $number, string $body): bool
|
||||
{
|
||||
$client = $this->client();
|
||||
if (! $client) {
|
||||
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,
|
||||
]);
|
||||
$response = $client->post("/issues/{$number}/comments", ['body' => $body]);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Appel API Gitea impossible : '.$e->getMessage());
|
||||
Log::error('Ajout de commentaire 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;
|
||||
return $response->successful();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user