From 7d52c1e440e025820d5fcfa81c50ec4f6f51f128 Mon Sep 17 00:00:00 2001 From: Sam DEPARDIEU Date: Wed, 8 Jul 2026 23:38:42 +0200 Subject: [PATCH] feat(tickets): suivi bidirectionnel des tickets utilisateur via Gitea 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 --- CHANGELOG.md | 2 +- .../Controllers/Web/SuggestionController.php | 59 -------- .../Http/Controllers/Web/TicketController.php | 131 ++++++++++++++++++ WEB/app/Models/Ticket.php | 28 ++++ WEB/app/Models/User.php | 8 ++ WEB/app/Services/GiteaIssueService.php | 128 +++++++++++++---- ...2026_07_08_000001_create_tickets_table.php | 32 +++++ WEB/resources/views/layouts/sidebar.blade.php | 12 +- .../{suggestions => tickets}/create.blade.php | 52 ++----- WEB/resources/views/tickets/index.blade.php | 49 +++++++ WEB/resources/views/tickets/show.blade.php | 90 ++++++++++++ WEB/routes/web.php | 11 +- WEB/tests/Feature/SuggestionTest.php | 63 --------- WEB/tests/Feature/TicketTest.php | 97 +++++++++++++ 14 files changed, 568 insertions(+), 194 deletions(-) delete mode 100644 WEB/app/Http/Controllers/Web/SuggestionController.php create mode 100644 WEB/app/Http/Controllers/Web/TicketController.php create mode 100644 WEB/app/Models/Ticket.php create mode 100644 WEB/database/migrations/2026_07_08_000001_create_tickets_table.php rename WEB/resources/views/{suggestions => tickets}/create.blade.php (63%) create mode 100644 WEB/resources/views/tickets/index.blade.php create mode 100644 WEB/resources/views/tickets/show.blade.php delete mode 100644 WEB/tests/Feature/SuggestionTest.php create mode 100644 WEB/tests/Feature/TicketTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c7288c..fe566d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ et le versionnage suit [SemVer](https://semver.org/lang/fr/). ## [Non publié] ### Ajouté -- Formulaire de suggestions/signalements (utilisateur connecté) créant un ticket dans Gitea via son API. +- Système de tickets connecté à Gitea : création, suivi de l'avancement (état, jalon, labels), fil de discussion et réponses depuis l'interface web. - Pipeline d'intégration continue Gitea Actions (tests, Pint, audit de sécurité). - Pipeline de déploiement continu (build image → registre Gitea → webhook Portainer). - Middleware `SecurityHeaders` (HSTS, CSP, X-Frame-Options, etc.). diff --git a/WEB/app/Http/Controllers/Web/SuggestionController.php b/WEB/app/Http/Controllers/Web/SuggestionController.php deleted file mode 100644 index bc5babe..0000000 --- a/WEB/app/Http/Controllers/Web/SuggestionController.php +++ /dev/null @@ -1,59 +0,0 @@ -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.", - ]); - } -} diff --git a/WEB/app/Http/Controllers/Web/TicketController.php b/WEB/app/Http/Controllers/Web/TicketController.php new file mode 100644 index 0000000..3f2176b --- /dev/null +++ b/WEB/app/Http/Controllers/Web/TicketController.php @@ -0,0 +1,131 @@ +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(); + } +} diff --git a/WEB/app/Models/Ticket.php b/WEB/app/Models/Ticket.php new file mode 100644 index 0000000..8fcc325 --- /dev/null +++ b/WEB/app/Models/Ticket.php @@ -0,0 +1,28 @@ +belongsTo(User::class); + } +} diff --git a/WEB/app/Models/User.php b/WEB/app/Models/User.php index aa6e9ef..5ccf83e 100644 --- a/WEB/app/Models/User.php +++ b/WEB/app/Models/User.php @@ -52,6 +52,14 @@ class User extends Authenticatable implements FilamentUser return $this->hasMany(EmotionRecord::class, 'user_id'); } + /** + * @return HasMany + */ + public function tickets(): HasMany + { + return $this->hasMany(Ticket::class); + } + public function favoritedActivities(): BelongsToMany { return $this->belongsToMany(RelaxationActivity::class, 'favorites', 'id_user', 'id_activite'); diff --git a/WEB/app/Services/GiteaIssueService.php b/WEB/app/Services/GiteaIssueService.php index 6de6bf8..b5ec975 100644 --- a/WEB/app/Services/GiteaIssueService.php +++ b/WEB/app/Services/GiteaIssueService.php @@ -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 ". + 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|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|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 + */ + 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 ". - $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(); } } diff --git a/WEB/database/migrations/2026_07_08_000001_create_tickets_table.php b/WEB/database/migrations/2026_07_08_000001_create_tickets_table.php new file mode 100644 index 0000000..8ec78e3 --- /dev/null +++ b/WEB/database/migrations/2026_07_08_000001_create_tickets_table.php @@ -0,0 +1,32 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->unsignedInteger('gitea_number'); // numéro de l'issue Gitea + $table->string('title'); + $table->string('type'); + $table->timestamps(); + + $table->index(['user_id', 'gitea_number']); + }); + } + + public function down(): void + { + Schema::dropIfExists('tickets'); + } +}; diff --git a/WEB/resources/views/layouts/sidebar.blade.php b/WEB/resources/views/layouts/sidebar.blade.php index 7527f6c..e3aba1b 100644 --- a/WEB/resources/views/layouts/sidebar.blade.php +++ b/WEB/resources/views/layouts/sidebar.blade.php @@ -50,15 +50,15 @@ Guides & Informations - + @auth - routeIs('suggestions.*') ? 'aria-current="page"' : '' !!}> -