Compare commits

..

15 Commits

Author SHA1 Message Date
sam a1b37c453d Ajout de l'apk
CI - Intégration continue / quality-and-tests (push) Successful in 3m24s
CD - Déploiement continu / deploy (push) Successful in 3m13s
2026-07-09 07:38:28 +00:00
sam 96b598c6f4 feat: formulaire de suggestions optimisé pour mobile
CI - Intégration continue / quality-and-tests (push) Successful in 3m23s
CD - Déploiement continu / deploy (push) Successful in 3m6s
Ré-appliqué sur la version avec suivi des suggestions du coéquipier.
- create.blade.php mobile-first : espacements/typo adaptatifs, champs en
  text-base (évite le zoom iOS), boutons empilés pleine largeur, compteur
  de caractères sur la description.
- header : bouton "Proposer une amélioration" accessible sur mobile
  (icône seule < sm) ; lien "Mes suggestions" conservé.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 22:12:33 +02:00
Azmog 2984b9f410 feat: ajout du suivi des suggestions avec intégration Gitea
CI - Intégration continue / quality-and-tests (push) Successful in 3m15s
CD - Déploiement continu / deploy (push) Successful in 3m10s
2026-07-08 09:59:05 +02:00
sam 507d77246c feat: page publique de téléchargement des applications mobiles
CI - Intégration continue / quality-and-tests (push) Successful in 3m16s
CD - Déploiement continu / deploy (push) Successful in 3m7s
Page /telecharger avec deux cartes (Android / iOS). Pour chaque plateforme,
utilise en priorité un lien de store (MOBILE_*_URL) sinon le binaire déposé
dans public/downloads/ (.apk / .ipa), et affiche "Bientôt disponible" à défaut.

- config/mobile.php + .env.example (URLs/versions)
- DownloadController + vue telecharger + lien dans le header (public)
- public/downloads/ (README) ; binaires ignorés par git
- tests: accès public, lien store prioritaire, indisponibilité

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 22:55:15 +02:00
sam dede70f70f feat: formulaire de suggestions créant un ticket Gitea
CI - Intégration continue / quality-and-tests (push) Successful in 3m56s
CD - Déploiement continu / deploy (push) Successful in 3m6s
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>
2026-07-07 20:00:12 +02:00
sam 54f2c0e9ab deploy: récupération git robuste (fetch + reset --hard)
CI - Intégration continue / quality-and-tests (push) Successful in 3m13s
CD - Déploiement continu / deploy (push) Successful in 3m2s
Le "git pull" s'abandonnait dès qu'un fichier suivi avait été modifié
localement sur le serveur ("local changes would be overwritten"). On
répare d'abord les permissions (proprio = déployeur) puis on fait
fetch + reset --hard origin/master : récupération propre, insensible aux
modifs locales parasites. Les fichiers non suivis (cert.pem, .env) sont
préservés.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 19:49:23 +02:00
sam e8c6733b40 deploy: permissions storage compatibles avec git
CI - Intégration continue / quality-and-tests (push) Successful in 3m10s
CD - Déploiement continu / deploy (push) Successful in 3m34s
Le chown -R www-data cassait les futurs git pull/reset (les .gitignore
suivis passaient à www-data, inaccessibles à l'utilisateur SSH).
Désormais : propriétaire = utilisateur de déploiement, groupe = www-data
+ écriture de groupe + setgid, pour que git ET php-fpm puissent écrire.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 19:44:55 +02:00
sam 63240f2e52 Corrige la redirection vers localhost derrière le reverse proxy
CI - Intégration continue / quality-and-tests (push) Successful in 3m9s
CD - Déploiement continu / deploy (push) Successful in 3m5s
- nginx.conf: sert l'application sur 80 et 443 sans redirection interne
  (l'ancien "return 301 https://\$server_name" renvoyait vers localhost,
  server_name étant codé à localhost). NPM gère déjà le TLS/redirect public.
- bootstrap/app.php: trustProxies(at: '*') pour que Laravel lise
  X-Forwarded-Proto/Host et génère les bonnes URL derrière NPM.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 19:33:47 +02:00
sam ab4f2cb403 Fixe composer.lock pour PHP 8.2 (platform pin)
CI - Intégration continue / quality-and-tests (push) Successful in 3m14s
CD - Déploiement continu / deploy (push) Successful in 3m3s
Le lock verrouillait des paquets exigeant PHP 8.4 (grumphp -> doctrine/
collections 3, symfony 8.1), incompatibles avec l'environnement PHP 8.2
(Dockerfile, CI). Ajout de config.platform.php=8.2.32 + régénération du
lock : composer install repasse sur PHP 8.2.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 14:13:27 +02:00
sam 4d560a03cc Renomme DOCKERFILE en Dockerfile (casse)
CD - Déploiement continu / deploy (push) Successful in 2m59s
CI - Intégration continue / quality-and-tests (push) Failing after 2m38s
Suivi en majuscules, le fichier était introuvable par Docker sur le
serveur Linux (sensible à la casse) : "open Dockerfile: no such file".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 13:48:54 +02:00
Azmog b568bc63a9 feat: ajout de la configuration SonarQube pour l'analyse de qualité du code et mise à jour de la CI
CI - Intégration continue / quality-and-tests (push) Failing after 3m8s
CD - Déploiement continu / deploy (push) Successful in 3m7s
2026-07-06 13:17:50 +02:00
Azmog 1e6557729f feat: add HasCompositePrimaryKey trait for composite primary key support
CD - Déploiement continu / deploy (push) Successful in 3m7s
CI - Intégration continue / quality-and-tests (push) Failing after 2m30s
- Introduced a new trait `HasCompositePrimaryKey` to enable Eloquent models to handle composite primary keys, which are not supported natively by Eloquent. The trait requires models to implement a method to define the composite key columns and manages key retrieval for save and select queries.

chore: add GrumPHP configuration for pre-commit checks

- Added a `grumphp.yml` configuration file to enforce code style and static analysis checks before commits. This setup includes Laravel Pint for code style and Larastan for static analysis, ensuring code quality and consistency.

chore: create PHPStan configuration file

- Created a `phpstan.neon` file to configure PHPStan with Larastan for static analysis. The configuration specifies the paths to analyze and sets the analysis level to 5 for a balance between catching bugs and avoiding excessive strictness.
2026-07-06 12:06:54 +02:00
sam ad6b6eb047 Dockerfile: ajoute libicu-dev + extension intl
CI - Intégration continue / quality-and-tests (push) Successful in 2m57s
CD - Déploiement continu / deploy (push) Successful in 3m2s
L'extension PHP intl nécessite la bibliothèque système libicu-dev,
absente jusqu'ici (échec du build: icu-uc not found).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 12:06:41 +02:00
sam 3b7b527462 CI: ajoute l'extension pdo_sqlite pour les tests
CI - Intégration continue / quality-and-tests (push) Successful in 2m52s
CD - Déploiement continu / deploy (push) Successful in 3m1s
Les tests SecuriteTest utilisent une base SQLite en mémoire (RefreshDatabase) ;
le runner manquait le driver pdo_sqlite/sqlite3.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 11:41:03 +02:00
sam 79c902cf32 Corrige la CSP (autorise Tailwind CDN) + guide Gitea Actions
CD - Déploiement continu / deploy (push) Successful in 3m10s
CI - Intégration continue / quality-and-tests (push) Failing after 2m31s
- SecurityHeaders: autorise cdn.tailwindcss.com dans script-src/style-src
  (sinon le style du site est bloqué en production)
- docs/05-mise-en-place-gitea-actions.md: guide d'installation du runner

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 11:18:40 +02:00
34 changed files with 4414 additions and 353 deletions
+15
View File
@@ -69,4 +69,19 @@ AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=
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
# --- Applications mobiles (page /telecharger) ---
# Laisser vide pour servir les fichiers de public/downloads/ (.apk / .ipa),
# ou renseigner un lien de store / TestFlight (prioritaire sur le fichier local).
MOBILE_ANDROID_URL=
MOBILE_IOS_URL=
MOBILE_ANDROID_VERSION=1.0.0
MOBILE_IOS_VERSION=1.0.0
VITE_APP_NAME="${APP_NAME}"
+35 -7
View File
@@ -15,16 +15,28 @@ jobs:
quality-and-tests:
runs-on: ubuntu-latest
# Jetons SonarQube, définis dans les secrets du dépôt Gitea.
# Exposés au niveau du job pour pouvoir conditionner l'étape d'analyse
# (elle ne s'exécute que si un jeton est configuré).
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }}
steps:
- name: Récupération du code
uses: actions/checkout@v4
with:
# Historique complet : SonarQube en a besoin pour dater le code
# et calculer correctement le "nouveau code".
fetch-depth: 0
- name: Installation de PHP 8.2
uses: shivammathur/setup-php@v2
with:
php-version: '8.2'
extensions: pdo_mysql, mbstring, exif, pcntl, bcmath, gd
coverage: none
extensions: pdo_mysql, pdo_sqlite, sqlite3, mbstring, exif, pcntl, bcmath, gd
# pcov : moteur de couverture rapide, requis pour le rapport SonarQube.
coverage: pcov
- name: Cache des dépendances Composer
uses: actions/cache@v4
@@ -43,8 +55,24 @@ jobs:
- name: Vérification du style de code (Laravel Pint)
run: vendor/bin/pint --test
- name: Exécution des tests automatisés (PHPUnit)
env:
DB_CONNECTION: sqlite
DB_DATABASE: ':memory:'
run: php artisan test
- name: Analyse statique (Larastan / PHPStan)
run: vendor/bin/phpstan analyse --configuration=phpstan.neon --memory-limit=512M --no-progress
- name: Exécution des tests + couverture (PHPUnit)
# phpunit.xml définit déjà l'environnement de test (sqlite en mémoire).
# On génère le rapport de couverture (clover) et le rapport de tests
# (junit) consommés ensuite par SonarQube.
run: |
mkdir -p coverage
vendor/bin/phpunit \
--coverage-clover=coverage/clover.xml \
--log-junit=coverage/junit.xml
- name: Analyse SonarQube
# Ne s'exécute que si le secret SONAR_TOKEN est configuré dans Gitea,
# sinon l'étape est ignorée (la CI reste verte tant que le serveur
# SonarQube n'est pas branché).
if: ${{ env.SONAR_TOKEN != '' }}
uses: sonarsource/sonarqube-scan-action@v5
# Le scanner lit sonar-project.properties et utilise SONAR_HOST_URL /
# SONAR_TOKEN exposés au niveau du job.
+26 -5
View File
@@ -30,8 +30,21 @@ jobs:
set -e
cd /home/sam/mon-app-laravel
# Récupération de la dernière version du code
git pull origin master
DEPLOY_UID=$(id -u)
# L'utilisateur de déploiement doit posséder storage/ & bootstrap/cache
# AVANT toute opération git (un déploiement précédent a pu les passer à
# www-data, ce qui bloquerait git). chown via le conteneur (= root).
docker exec -u 0 laravel_php chown -R "${DEPLOY_UID}:www-data" \
/var/www/storage /var/www/bootstrap/cache
# Récupération propre de la dernière version : on ÉCRASE toute modif
# locale parasite (le serveur est une copie de déploiement, aucune
# édition manuelle n'est censée y vivre). Évite les abandons du type
# "local changes would be overwritten by merge". Les fichiers non
# suivis (cert.pem, .env…) ne sont PAS touchés par reset --hard.
git fetch origin master
git reset --hard origin/master
# Reconstruction et redémarrage des conteneurs
docker compose up -d --build
@@ -44,6 +57,14 @@ jobs:
docker exec laravel_php php artisan route:cache
docker exec laravel_php php artisan view:cache
# Correction des permissions sur les dossiers écrits par Laravel
docker exec -u 0 laravel_php chown -R www-data:www-data \
/var/www/storage /var/www/bootstrap/cache
# Correction des permissions sur les dossiers écrits par Laravel.
# Laravel (www-data) doit pouvoir écrire dans storage/ et
# bootstrap/cache, MAIS l'utilisateur SSH doit rester propriétaire
# pour que les futurs "git pull/reset" ne soient pas bloqués (sinon
# les .gitignore suivis passent à www-data et git échoue).
# => propriétaire = utilisateur de déploiement, groupe = www-data,
# avec écriture de groupe + setgid (héritage du groupe www-data).
docker exec -u 0 laravel_php sh -c "\
chown -R ${DEPLOY_UID}:www-data /var/www/storage /var/www/bootstrap/cache && \
chmod -R ug+rwX /var/www/storage /var/www/bootstrap/cache && \
find /var/www/storage /var/www/bootstrap/cache -type d -exec chmod g+s {} +"
+4
View File
@@ -9,13 +9,17 @@
/.idea
/.nova
/.phpunit.cache
/.scannerwork
/.vscode
/coverage
/.zed
/auth.json
/node_modules
/public/build
/public/hot
/public/storage
/public/downloads/*.apk
/public/downloads/*.ipa
/storage/*.key
/storage/pail
/vendor
+3 -2
View File
@@ -1,14 +1,15 @@
FROM php:8.2-fpm
# Installation des dépendances système
# libicu-dev est requis pour l'extension PHP intl.
RUN apt-get update && apt-get install -y \
git curl libpng-dev libonig-dev libxml2-dev zip unzip
git curl libpng-dev libonig-dev libxml2-dev libicu-dev zip unzip
# Nettoyage du cache
RUN apt-get clean && rm -rf /var/lib/apt/lists/*
# Installation des extensions PHP pour Laravel et MySQL
RUN docker-php-ext-install pdo_mysql mbstring exif pcntl bcmath gd
RUN docker-php-ext-install pdo_mysql mbstring exif pcntl bcmath gd intl
# Récupération de Composer
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
@@ -0,0 +1,43 @@
<?php
namespace App\Http\Controllers;
class DownloadController extends Controller
{
// Page publique de téléchargement des applications mobiles
public function index()
{
return view('telecharger', [
'android' => $this->resoudre('android'),
'ios' => $this->resoudre('ios'),
]);
}
/**
* Détermine, pour une plateforme, le lien de téléchargement et sa disponibilité.
*
* @return array{version: string, url: string|null, disponible: bool}
*/
private function resoudre(string $plateforme): array
{
$config = config("mobile.{$plateforme}");
if (! is_array($config)) {
$config = [];
}
$version = is_string($config['version'] ?? null) ? $config['version'] : '1.0.0';
$url = (is_string($config['url'] ?? null) && $config['url'] !== '') ? $config['url'] : null;
$file = is_string($config['file'] ?? null) ? $config['file'] : null;
// À défaut d'URL explicite, on propose le fichier précompilé s'il existe.
if ($url === null && $file !== null && file_exists(public_path($file))) {
$url = asset($file);
}
return [
'version' => $version,
'url' => $url,
'disponible' => $url !== null,
];
}
}
@@ -0,0 +1,151 @@
<?php
namespace App\Http\Controllers;
use App\Models\Suggestion;
use App\Models\Utilisateur;
use App\Services\GiteaIssueService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
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._';
$numero = $gitea->createIssue($title, $body);
if ($numero === null) {
return back()->withInput()->withErrors([
'gitea' => "L'envoi de votre suggestion a échoué. Merci de réessayer plus tard.",
]);
}
// La suggestion est conservée en base, liée au ticket, pour le suivi.
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.');
}
}
+4 -2
View File
@@ -38,10 +38,12 @@ class SecurityHeaders
// Content-Security-Policy : réduit fortement le risque d'injection de scripts (XSS).
// Politique volontairement compatible avec Laravel/Vite (styles et scripts inline autorisés).
// Pour durcir davantage, remplacer 'unsafe-inline' par un système de nonce.
// NB : l'application charge Tailwind via CDN (cdn.tailwindcss.com) et
// les avatars via ui-avatars.com : ces sources externes sont donc autorisées.
$response->headers->set('Content-Security-Policy', implode('; ', [
"default-src 'self'",
"script-src 'self' 'unsafe-inline'",
"style-src 'self' 'unsafe-inline'",
"script-src 'self' 'unsafe-inline' https://cdn.tailwindcss.com",
"style-src 'self' 'unsafe-inline' https://cdn.tailwindcss.com",
"img-src 'self' data: https:",
"font-src 'self' data:",
"connect-src 'self'",
@@ -0,0 +1,101 @@
<?php
namespace App\Models\Concerns;
use Illuminate\Database\Eloquent\Builder;
/**
* Apprend à un modèle Eloquent à gérer une clé primaire COMPOSITE
* (plusieurs colonnes), ce qu'Eloquent ne sait pas faire nativement.
*
* Eloquent suppose que `$primaryKey` est une seule colonne (chaîne). On garde
* donc `$primaryKey` sous forme de chaîne (contrat respecté) et on déclare les
* colonnes de la clé composite via compositeKeyColumns(). Les requêtes de
* mise à jour / suppression / sélection par clé filtrent alors sur CHAQUE
* colonne de la clé.
*
* Le modèle qui l'utilise doit :
* - déclarer `public $incrementing = false;`
* - implémenter compositeKeyColumns() en listant les colonnes de la clé.
*/
trait HasCompositePrimaryKey
{
/**
* Colonnes qui composent la clé primaire composite.
*
* @return list<string>
*/
abstract protected function compositeKeyColumns(): array;
/**
* Valeur de la clé sous forme de tableau « colonne => valeur ».
*
* @return array<string, mixed>
*/
public function getKey()
{
$key = [];
foreach ($this->compositeKeyColumns() as $column) {
$key[$column] = $this->getAttribute($column);
}
return $key;
}
/**
* Cible chaque colonne de la clé composite lors d'un save / update.
*
* @param Builder<static> $query
* @return Builder<static>
*/
protected function setKeysForSaveQuery($query)
{
foreach ($this->compositeKeyColumns() as $column) {
$query->where($column, '=', $this->getKeyForSaveQuery($column));
}
return $query;
}
/**
* Valeur d'une colonne de la clé pour un save (valeur d'origine si dispo).
*
* @param string|null $column
* @return mixed
*/
protected function getKeyForSaveQuery($column = null)
{
$column ??= $this->getKeyName();
return $this->original[$column] ?? $this->getAttribute($column);
}
/**
* Cible chaque colonne de la clé composite lors d'une sélection par clé.
*
* @param Builder<static> $query
* @return Builder<static>
*/
protected function setKeysForSelectQuery($query)
{
foreach ($this->compositeKeyColumns() as $column) {
$query->where($column, '=', $this->getKeyForSelectQuery($column));
}
return $query;
}
/**
* Valeur d'une colonne de la clé pour une sélection.
*
* @param string|null $column
* @return mixed
*/
protected function getKeyForSelectQuery($column = null)
{
$column ??= $this->getKeyName();
return $this->original[$column] ?? $this->getAttribute($column);
}
}
+45
View File
@@ -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');
}
}
+17 -1
View File
@@ -2,17 +2,33 @@
namespace App\Models;
use App\Models\Concerns\HasCompositePrimaryKey;
use Illuminate\Database\Eloquent\Model;
class Utilisateur_Progression extends Model
{
use HasCompositePrimaryKey;
protected $table = 'utilisateur_progressions';
public $timestamps = false;
public $incrementing = false;
protected $primaryKey = ['ID_Utilisateur', 'ID_Ressource'];
// Clé primaire composite (ID_Utilisateur + ID_Ressource), gérée par le
// trait via compositeKeyColumns(). Eloquent impose une chaîne pour
// $primaryKey : on y met la 1re colonne pour rester conforme au contrat.
protected $primaryKey = 'ID_Utilisateur';
/**
* Colonnes composant la clé primaire composite.
*
* @return list<string>
*/
protected function compositeKeyColumns(): array
{
return ['ID_Utilisateur', 'ID_Ressource'];
}
protected $fillable = [
'ID_Utilisateur',
+180
View File
@@ -0,0 +1,180 @@
<?php
namespace App\Services;
use Illuminate\Http\Client\PendingRequest;
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 int|null le numéro du ticket créé, ou null en cas d'échec.
*/
public function createIssue(string $title, string $body): ?int
{
$client = $this->client();
if ($client === null) {
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;
}
try {
$response = $client->post($this->repoPath().'/issues/'.$number.'/comments', [
'body' => $body,
]);
} catch (\Throwable $e) {
Log::error('Ajout du commentaire Gitea impossible : '.$e->getMessage());
return false;
}
if ($response->failed()) {
Log::error('Ajout du commentaire Gitea échoué', [
'status' => $response->status(),
'body' => $response->body(),
]);
return false;
}
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();
}
}
+5
View File
@@ -13,6 +13,11 @@ return Application::configure(basePath: dirname(__DIR__))
health: '/up',
)
->withMiddleware(function (Middleware $middleware): void {
// L'application tourne derrière nginx-proxy-manager : on fait confiance
// au reverse proxy pour lire X-Forwarded-Proto/Host (sinon Laravel croit
// être en HTTP et génère de mauvaises URL / redirections).
$middleware->trustProxies(at: '*');
$middleware->append(ForceHttps::class);
$middleware->append(SecurityHeaders::class);
})
+7 -1
View File
@@ -12,11 +12,13 @@
},
"require-dev": {
"fakerphp/faker": "^1.23",
"larastan/larastan": "^3.10",
"laravel/pail": "^1.2.2",
"laravel/pint": "^1.24",
"laravel/sail": "^1.41",
"mockery/mockery": "^1.6",
"nunomaduro/collision": "^8.6",
"phpro/grumphp": "^2.22",
"phpunit/phpunit": "^11.5.50"
},
"autoload": {
@@ -76,9 +78,13 @@
"optimize-autoloader": true,
"preferred-install": "dist",
"sort-packages": true,
"platform": {
"php": "8.2.32"
},
"allow-plugins": {
"pestphp/pest-plugin": true,
"php-http/discovery": true
"php-http/discovery": true,
"phpro/grumphp": true
}
},
"minimum-stability": "stable",
Generated
+2760 -327
View File
File diff suppressed because it is too large Load Diff
+29
View File
@@ -0,0 +1,29 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Applications mobiles (téléchargement)
|--------------------------------------------------------------------------
|
| Pour chaque plateforme, la page de téléchargement utilise en priorité
| l'URL fournie (lien de store OU URL directe). À défaut, elle propose le
| fichier précompilé déposé dans public/downloads/. Si aucun des deux n'est
| disponible, le bouton affiche « bientôt disponible ».
|
*/
'android' => [
'version' => env('MOBILE_ANDROID_VERSION', '1.0.0'),
'url' => env('MOBILE_ANDROID_URL'),
'file' => 'downloads/ressources-relationnelles.apk',
],
'ios' => [
'version' => env('MOBILE_IOS_VERSION', '1.0.0'),
'url' => env('MOBILE_IOS_URL'),
'file' => 'downloads/ressources-relationnelles.ipa',
],
];
+8
View File
@@ -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'),
],
];
@@ -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');
}
};
+43
View File
@@ -0,0 +1,43 @@
# Serveur SonarQube (auto-hébergé) pour l'analyse de qualité du code.
#
# Démarrage : docker compose -f docker-compose.sonar.yml up -d
# Interface : http://localhost:9000 (identifiants par défaut : admin / admin)
# Arrêt : docker compose -f docker-compose.sonar.yml down
#
# ⚠️ Prérequis hôte (Elasticsearch embarqué dans SonarQube) :
# Linux : sudo sysctl -w vm.max_map_count=262144
# (à rendre permanent dans /etc/sysctl.conf)
# Prévoir ~2-3 Go de RAM disponibles pour le conteneur.
services:
sonarqube:
image: sonarqube:community
container_name: sonarqube
depends_on:
- sonar-db
ports:
- "9000:9000"
environment:
SONAR_JDBC_URL: jdbc:postgresql://sonar-db:5432/sonar
SONAR_JDBC_USERNAME: sonar
SONAR_JDBC_PASSWORD: sonar
volumes:
- sonarqube_data:/opt/sonarqube/data
- sonarqube_extensions:/opt/sonarqube/extensions
- sonarqube_logs:/opt/sonarqube/logs
sonar-db:
image: postgres:15
container_name: sonar-db
environment:
POSTGRES_USER: sonar
POSTGRES_PASSWORD: sonar
POSTGRES_DB: sonar
volumes:
- sonar_postgresql:/var/lib/postgresql/data
volumes:
sonarqube_data:
sonarqube_extensions:
sonarqube_logs:
sonar_postgresql:
+148
View File
@@ -0,0 +1,148 @@
# 05 — Mise en place de Gitea Actions (CI/CD)
Guide d'installation du moteur d'automatisation (CI/CD) pour le dépôt.
Contexte : **Gitea en Docker / docker-compose**, accès **SSH** au serveur.
Gitea Actions nécessite deux choses :
1. **Activer** la fonctionnalité Actions dans Gitea.
2. Enregistrer au moins un **runner** (`act_runner`) qui exécute les workflows.
---
## Étape 1 — Activer Actions dans Gitea
Se connecter en SSH au serveur, puis se placer dans le dossier du `docker-compose.yml`
**de Gitea** (⚠️ celui de Gitea, pas celui de l'application Laravel).
### Option A (recommandée) — via variable d'environnement
Dans le service Gitea du `docker-compose.yml`, ajouter la variable :
```yaml
services:
server: # nom du service Gitea (peut s'appeler "gitea")
image: gitea/gitea:latest
environment:
- GITEA__actions__ENABLED=true
# ... reste de la configuration inchangé
```
Puis recharger Gitea :
```bash
docker compose up -d
```
### Option B — via le fichier app.ini
Éditer le fichier `app.ini` (dans le volume Gitea, souvent
`./gitea/gitea/conf/app.ini`) et ajouter :
```ini
[actions]
ENABLED = true
```
Puis redémarrer : `docker compose restart`.
### Vérification
Dans l'interface web du dépôt, un onglet **« Actions »** doit apparaître.
---
## Étape 2 — Obtenir un jeton d'enregistrement du runner
Deux niveaux possibles :
- **Niveau instance** (runner partagé par tous les dépôts) :
`Site Administration → Actions → Runners → Create new Runner`.
- **Niveau dépôt** (runner dédié au projet) :
`Dépôt → Settings → Actions → Runners → Create new Runner`.
Copier le **jeton (registration token)** affiché.
---
## Étape 3 — Lancer un runner (act_runner) en Docker
Créer un dossier de travail sur le serveur, par exemple `~/gitea-runner`, avec un
fichier `docker-compose.yml` :
```yaml
services:
runner:
image: gitea/act_runner:latest
container_name: gitea_runner
restart: always
environment:
GITEA_INSTANCE_URL: "https://gitea.sam-coffre.duckdns.org"
GITEA_RUNNER_REGISTRATION_TOKEN: "COLLER_LE_JETON_ICI"
GITEA_RUNNER_NAME: "runner-docker-1"
# Images utilisées pour `runs-on: ubuntu-latest`
GITEA_RUNNER_LABELS: "ubuntu-latest:docker://catthehacker/ubuntu:act-latest"
volumes:
- ./data:/data
- /var/run/docker.sock:/var/run/docker.sock
```
> Le montage de `/var/run/docker.sock` permet au runner de créer les conteneurs
> qui exécutent chaque job. L'image `catthehacker/ubuntu:act-latest` fournit un
> environnement compatible `ubuntu-latest` (Node, outils de base).
Démarrer le runner :
```bash
cd ~/gitea-runner
docker compose up -d
docker compose logs -f runner # doit afficher "runner registered successfully"
```
Le runner apparaît alors en vert dans `Settings → Actions → Runners` du dépôt.
---
## Étape 4 — Ajouter les secrets de déploiement
Dans `Dépôt → Settings → Actions → Secrets`, créer :
| Secret | Valeur |
|--------|--------|
| `SSH_HOST` | IP ou domaine du serveur de production |
| `SSH_USER` | utilisateur SSH (ex. `sam`) |
| `SSH_PRIVATE_KEY` | clé privée SSH autorisée sur le serveur |
| `SSH_PORT` | port SSH (ex. `22`) |
> Générer une paire de clés dédiée au déploiement :
> `ssh-keygen -t ed25519 -C "gitea-deploy" -f deploy_key`
> puis ajouter `deploy_key.pub` dans `~/.ssh/authorized_keys` du serveur, et
> coller le contenu de `deploy_key` (clé privée) dans le secret `SSH_PRIVATE_KEY`.
---
## Étape 5 — Déclencher et vérifier
Un simple push sur `master` déclenche les workflows :
```bash
git commit --allow-empty -m "test: déclenche la CI"
git push
```
Dans l'onglet **Actions** du dépôt :
- le workflow **CI** (`ci.yml`) exécute Pint puis PHPUnit ;
- le workflow **CD** (`deploy.yml`) déploie via SSH.
Une exécution en vert constitue une **preuve idéale à intégrer au dossier** (capture d'écran).
---
## Dépannage rapide
| Symptôme | Cause probable / solution |
|----------|---------------------------|
| Pas d'onglet Actions | Actions non activé (Étape 1) ou Gitea non redémarré |
| « No runner available » | Runner non enregistré / hors ligne (Étape 3) |
| `ubuntu-latest` introuvable | Label non défini → variable `GITEA_RUNNER_LABELS` |
| `actions/checkout` échoue | Le serveur doit accéder à github.com (Gitea proxy les actions) |
| Déploiement SSH refusé | Vérifier la clé dans `authorized_keys` et les secrets SSH_* |
+26
View File
@@ -0,0 +1,26 @@
# Configuration GrumPHP : vérifications automatiques avant chaque commit.
# À chaque `git commit`, GrumPHP lance ces tâches ; si l'une échoue,
# le commit est annulé. On évite ainsi de pousser du code non formaté
# ou contenant des erreurs détectables statiquement (= CI qui reste verte).
grumphp:
# On laisse le temps aux analyses (Larastan peut être un peu long).
process_timeout: 120
# On n'arrête pas au premier échec : on veut voir TOUS les problèmes
# (Pint ET PHPStan) en une seule fois.
stop_on_failure: false
tasks:
# 1) Style de code (Laravel Pint) — mêmes règles qu'en CI.
shell:
scripts:
- ['-c', 'vendor/bin/pint --test']
# 2) Analyse statique (Larastan / PHPStan) — détecte les vrais bugs.
phpstan:
configuration: phpstan.neon
# false => on analyse les chemins définis dans phpstan.neon (app/)
# plutôt que seulement les fichiers modifiés : analyse plus fiable.
use_grumphp_paths: false
# Larastan charge tout le framework : 128M par défaut ne suffit pas.
memory_limit: '512M'
+7 -6
View File
@@ -1,14 +1,15 @@
# Ce serveur nginx est DERRIÈRE nginx-proxy-manager (NPM), qui termine le TLS
# public (certificat Let's Encrypt) et force déjà HTTP -> HTTPS pour le domaine.
# Ici, on se contente donc de SERVIR l'application, sans redirection interne
# (l'ancienne redirection vers https://$server_name renvoyait vers "localhost").
server {
listen 80;
server_name localhost;
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl;
server_name localhost;
server_name app.sam-coffre.duckdns.org localhost _;
ssl_certificate /var/www/cert.pem;
ssl_certificate_key /var/www/cert.key;
index index.php index.html;
root /var/www/public;
+11
View File
@@ -0,0 +1,11 @@
includes:
- vendor/larastan/larastan/extension.neon
parameters:
paths:
- app/
# Niveau d'analyse statique (0 = souple, 9/max = très strict).
# On démarre à 5 : bon compromis pour attraper les vrais bugs
# (typage, null, méthodes inexistantes) sans bloquer sur des détails.
level: 5
+25
View File
@@ -0,0 +1,25 @@
# Applications mobiles précompilées
Déposez ici les binaires servis par la page **/telecharger** :
- `ressources-relationnelles.apk` — application Android
- `ressources-relationnelles.ipa` — application Apple (iOS)
La page détecte automatiquement la présence de ces fichiers.
## Alternative : liens de store
Plutôt que des fichiers locaux, vous pouvez renseigner des liens (Play Store,
App Store, TestFlight…) dans le fichier `.env` du serveur :
```
MOBILE_ANDROID_URL=https://play.google.com/store/apps/details?id=...
MOBILE_IOS_URL=https://apps.apple.com/app/id...
MOBILE_ANDROID_VERSION=1.0.0
MOBILE_IOS_VERSION=1.0.0
```
Si une URL est renseignée, elle est prioritaire sur le fichier local.
Si ni URL ni fichier ne sont disponibles, le bouton affiche « Bientôt disponible ».
> Les binaires (`*.apk`, `*.ipa`) ne sont pas versionnés dans git (voir `.gitignore`).
Binary file not shown.
+13 -1
View File
@@ -23,7 +23,16 @@
</div>
<nav class="flex items-center gap-2 whitespace-nowrap">
<a href="{{ route('telecharger') }}" class="hidden lg:inline-flex items-center px-4 py-2.5 text-sm font-bold text-gray-700 rounded-lg hover:bg-gray-100 transition-smooth duration-200">
📱 Télécharger l'app
</a>
@auth
<a href="{{ route('suggestions.create') }}"
title="Proposer une amélioration"
aria-label="Proposer une amélioration"
class="inline-flex items-center px-3 sm: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">
💡<span class="hidden sm:inline">&nbsp;Proposer une amélioration</span>
</a>
<div class="relative group">
<button class="flex items-center gap-2 p-2 rounded-lg hover:bg-gray-100 transition-smooth duration-200">
<img
@@ -36,7 +45,10 @@
<!-- 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">
<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
</a>
<form action="{{ route('logout') }}" method="POST">
@@ -0,0 +1,123 @@
@extends('app')
@section('title', 'Proposer une amélioration - (RE)Sources Relationnelles')
@section('content')
<section class="container mx-auto px-4 py-8 sm:py-12">
<div class="max-w-2xl mx-auto">
<!-- En-tête -->
<div class="mb-6 sm:mb-8">
<h1 class="text-2xl sm:text-4xl font-bold text-gray-900 mb-2">💡 Proposer une amélioration</h1>
<p class="text-sm sm:text-base 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 text-sm sm:text-base"> {{ 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-5 sm:p-8">
<form action="{{ route('suggestions.store') }}" method="POST" class="space-y-5 sm: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 text-base 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 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 text-base 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>
<div class="flex items-baseline justify-between mb-2">
<label for="description" class="block text-sm font-bold text-gray-700">Description détaillée</label>
<span id="desc-count" class="text-xs text-gray-400 tabular-nums">0 / 5000</span>
</div>
<textarea
id="description"
name="description"
required
minlength="10"
maxlength="5000"
rows="6"
placeholder="Décrivez précisément votre proposition : quoi, où, pourquoi…"
class="w-full px-4 py-3 text-base 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 : empilés pleine largeur sur mobile, en ligne sur desktop -->
<div class="flex flex-col sm:flex-row gap-3 pt-2">
<button type="submit" class="w-full sm:w-auto px-8 py-3 gradient-niagara text-white font-bold rounded-lg hover:shadow-lg transition-smooth duration-200 text-center">
📨 Envoyer ma suggestion
</button>
<a href="{{ route('home') }}" class="w-full sm:w-auto px-8 py-3 bg-gray-200 text-gray-900 font-bold rounded-lg hover:bg-gray-300 transition-smooth duration-200 text-center">
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
@push('scripts')
<script>
(function () {
const ta = document.getElementById('description');
const compteur = document.getElementById('desc-count');
if (!ta || !compteur) return;
const maj = () => { compteur.textContent = ta.value.length + ' / 5000'; };
ta.addEventListener('input', maj);
maj();
})();
</script>
@endpush
@@ -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 {{ $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
+105
View File
@@ -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 {{ $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
+78
View File
@@ -0,0 +1,78 @@
@extends('app')
@section('title', "Télécharger l'application - (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="text-center mb-10">
<h1 class="text-4xl font-bold text-gray-900 mb-3">📱 Télécharger l'application</h1>
<p class="text-gray-600 max-w-xl mx-auto">
Emportez (RE)Sources Relationnelles partout avec vous. Choisissez votre plateforme
et installez l'application mobile.
</p>
</div>
<div class="grid gap-6 md:grid-cols-2">
{{-- Carte Android --}}
<div class="bg-white rounded-2xl border border-gray-200 p-8 flex flex-col items-center text-center shadow-sm">
<div class="w-16 h-16 rounded-2xl bg-green-50 flex items-center justify-center mb-4">
<span class="text-4xl">🤖</span>
</div>
<h2 class="text-2xl font-bold text-gray-900 mb-1">Android</h2>
<p class="text-sm text-gray-500 mb-6">Version {{ $android['version'] }}</p>
@if ($android['disponible'])
<a href="{{ $android['url'] }}"
@if (str_starts_with($android['url'], url('/'))) download @else target="_blank" rel="noopener" @endif
class="w-full px-6 py-3 gradient-niagara text-white font-bold rounded-lg hover:shadow-lg transition-smooth duration-200">
⬇️ Télécharger (.apk)
</a>
<p class="text-xs text-gray-400 mt-3">
Sur Android, autorisez l'installation depuis « sources inconnues » si nécessaire.
</p>
@else
<button type="button" disabled
class="w-full px-6 py-3 bg-gray-200 text-gray-500 font-bold rounded-lg cursor-not-allowed">
Bientôt disponible
</button>
@endif
</div>
{{-- Carte iOS --}}
<div class="bg-white rounded-2xl border border-gray-200 p-8 flex flex-col items-center text-center shadow-sm">
<div class="w-16 h-16 rounded-2xl bg-gray-100 flex items-center justify-center mb-4">
<span class="text-4xl"></span>
</div>
<h2 class="text-2xl font-bold text-gray-900 mb-1">Apple (iOS)</h2>
<p class="text-sm text-gray-500 mb-6">Version {{ $ios['version'] }}</p>
@if ($ios['disponible'])
<a href="{{ $ios['url'] }}"
@if (str_starts_with($ios['url'], url('/'))) download @else target="_blank" rel="noopener" @endif
class="w-full px-6 py-3 bg-gray-900 text-white font-bold rounded-lg hover:bg-black transition-smooth duration-200">
⬇️ Télécharger
</a>
<p class="text-xs text-gray-400 mt-3">
L'installation d'un fichier .ipa nécessite TestFlight ou un profil adapté.
</p>
@else
<button type="button" disabled
class="w-full px-6 py-3 bg-gray-200 text-gray-500 font-bold rounded-lg cursor-not-allowed">
Bientôt disponible
</button>
@endif
</div>
</div>
<div class="mt-10 text-center">
<a href="{{ route('home') }}" class="text-sm font-semibold text-niagara hover:underline">
Retour à l'accueil
</a>
</div>
</div>
</section>
@endsection
+14
View File
@@ -1,7 +1,9 @@
<?php
use App\Http\Controllers\DownloadController;
use App\Http\Controllers\LoginController;
use App\Http\Controllers\ProfileController;
use App\Http\Controllers\SuggestionController;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
@@ -34,6 +36,9 @@ Route::get('/resources', function () {
return view('resources');
})->name('resources.index');
// Téléchargement des applications mobiles (public)
Route::get('/telecharger', [DownloadController::class, 'index'])->name('telecharger');
// Routes d'authentification
Route::get('/login', [LoginController::class, 'login'])->name('login');
Route::post('/login', [LoginController::class, 'store'])->name('login.store');
@@ -48,4 +53,13 @@ Route::middleware('auth')->group(function () {
Route::get('/profile/settings', [ProfileController::class, 'settings'])->name('profile.settings');
Route::put('/profile/update', [ProfileController::class, 'update'])->name('profile.update');
Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy');
// 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::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');
});
+18
View File
@@ -0,0 +1,18 @@
# Configuration de l'analyse SonarQube.
# L'URL du serveur (sonar.host.url) et le jeton (sonar.token) NE sont PAS ici :
# ils sont fournis à la CI via les secrets SONAR_HOST_URL / SONAR_TOKEN.
sonar.projectKey=ressources-relationnelles
sonar.projectName=Ressources Relationnelles
# Code source analysé et code de test.
sonar.sources=app
sonar.tests=tests
sonar.sourceEncoding=UTF-8
# Rapports produits par PHPUnit (générés dans la CI, voir ci.yml).
sonar.php.coverage.reportPaths=coverage/clover.xml
sonar.php.tests.reportPath=coverage/junit.xml
# On n'analyse pas le code tiers, généré ou compilé.
sonar.exclusions=vendor/**,storage/**,bootstrap/cache/**,public/build/**,node_modules/**
+226
View File
@@ -0,0 +1,226 @@
<?php
namespace Tests\Feature;
use App\Models\Role;
use App\Models\Suggestion;
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(string $pseudo = 'JeanTest', string $email = 'jean.test@example.com'): Utilisateur
{
$role = Role::firstOrCreate(['Nom' => 'Utilisateur']);
return Utilisateur::create([
'Pseudo' => $pseudo,
'Email' => $email,
'MotDePasse' => Hash::make('motdepasse123'),
'ID_Role' => $role->ID_Role,
'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
{
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');
$this->get('/suggestions')->assertRedirect('/login');
}
public function test_une_suggestion_valide_cree_un_ticket_gitea_et_est_conservee(): void
{
$this->configurerGitea();
Http::fake([
'*/api/v1/repos/*/issues' => Http::response(['number' => 42], 201),
]);
$utilisateur = $this->utilisateur();
$response = $this->actingAs($utilisateur)->post('/suggestions', [
'type' => 'Correction',
'titre' => 'Faute dans une ressource',
'description' => 'Il y a une faute à corriger sur la page des ressources.',
]);
// L'utilisateur arrive sur la page de suivi de ses suggestions
$response->assertRedirect(route('suggestions.index'));
$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');
});
// 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
{
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');
// Rien n'est conservé si le ticket n'a pas pu être créé
$this->assertDatabaseCount('suggestions', 0);
}
}
+37
View File
@@ -0,0 +1,37 @@
<?php
namespace Tests\Feature;
use Tests\TestCase;
class TelechargementTest extends TestCase
{
public function test_la_page_de_telechargement_est_accessible_publiquement(): void
{
$response = $this->get('/telecharger');
$response->assertStatus(200);
$response->assertSee('Android');
$response->assertSee('iOS');
}
public function test_un_lien_de_store_configure_est_utilise(): void
{
config(['mobile.android.url' => 'https://play.google.com/store/apps/details?id=test']);
$response = $this->get('/telecharger');
$response->assertStatus(200);
$response->assertSee('play.google.com/store/apps/details?id=test');
}
public function test_sans_binaire_ni_lien_le_bouton_est_indisponible(): void
{
config(['mobile.ios.url' => null]);
$response = $this->get('/telecharger');
$response->assertStatus(200);
$response->assertSee('Bientôt disponible');
}
}