Commit technique : reproduit à l'identique l'état non commité de la copie de travail (sécurisation V7.8, CSP à nonce, Session, migration des droits) afin que le travail d'ergonomie qui suit soit isolable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
10 KiB
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Contexte
PTA (Planning de Travail Annualisé) — application PHP 8.3 multipage (MVC maison) pour la planification annualisée des agents d'accueils de loisirs périscolaires/extrascolaires.
Aucune dépendance : pas de Composer, pas de npm, pas de framework, pas de bundler, pas de
suite de tests. Uniquement PHP + PDO/MySQL + JavaScript vanilla. Ne pas introduire de dépendance
sans raison forte : le PDF (app/Services/SimplePdf.php) et l'autoloader (app/autoload.php) sont
volontairement réécrits à la main.
Tout est en français : identifiants SQL (creneau_horaire, id_agent, statut), messages
utilisateur, commentaires. Conserver cette langue pour tout nouveau code.
Commandes
# Démarrage complet (nginx :8080, phpMyAdmin :8081, MariaDB, php-fpm)
cp .env.example .env # puis renseigner DB_PASSWORD / DB_ROOT_PASSWORD
docker compose up -d --build
# IMPORTANT : le code source est COPIÉ dans l'image au build (Dockerfile), il n'est pas monté.
# Toute modification PHP/JS/CSS exige un rebuild des DEUX services :
# (nginx sert lui-même les assets statiques depuis sa propre copie de /var/www/html)
docker compose up -d --build php nginx
docker compose logs -f php nginx db
docker compose ps
docker compose down # arrêt en conservant les données
docker compose down -v # DÉTRUIT le volume MariaDB
# Accès SQL direct
docker compose exec db mariadb -upta -p pta
# Vérification syntaxique (il n'existe ni linter ni tests automatisés)
find . -name '*.php' -not -path './.git/*' -exec php -l {} \;
Le schéma database/pta_prod_schema_mariadb.sql n'est joué qu'à la création initiale du volume
MariaDB (docker-entrypoint-initdb.d). Sur une base existante, toute évolution de schéma doit être
appliquée manuellement en SQL — il n'y a pas d'outil de migration.
Architecture
Deux types de points d'entrée, tous à la racine ou dans api/
Il n'y a pas de routeur. Chaque URL correspond à un fichier PHP réel de 5 à 10 lignes.
Pages HTML — planning.php, agents.php, annual.php, pta.php, teams.php, coverage.php,
structures.php, pending.php, vacations.php, administration.php :
require __DIR__ . '/page_bootstrap.php';
runPage('planning'); // -> PageController::planning()
page_bootstrap.php attrape les Throwable et affiche une page d'erreur HTML.
Endpoints JSON/PDF — api/*.php plus save_planning.php, validate_planning.php,
reopen_planning.php (ces trois-là sont restés à la racine pour raisons historiques) :
JsonResponse::bootstrap(); // handlers d'erreur -> JSON, jamais du HTML
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
(new PlanningController($pdo))->show();
JsonResponse::bootstrap() convertit warnings et exceptions en réponses JSON 500 — indispensable,
car le JS refuse toute réponse non-JSON. Les endpoints PDF ne l'appellent pas (ils émettent des
octets binaires via PdfResponse::inline()).
Pour ajouter un endpoint : créer le fichier mince dans api/, ajouter la méthode au contrôleur.
Pour ajouter une page : fichier racine + méthode dans PageController + vue app/Views/pages/ +
partiel app/Views/partials/ + module JS + inscription de la clé dans Access::allowedPages()
et dans le tableau $navigation de app/Views/layouts/header.php.
Chaîne d'un appel
api/x.php → Controller (Access + Csrf + Request) → Model (SQL PDO) → JsonResponse::send()
page.php → PageController → Models → Views/pages/x.php
├── layouts/header.php (nav filtrée par rôle)
├── partials/x.php (markup)
└── layouts/footer.php (injecte window.PTA_CONFIG + scripts)
app/bootstrap.php démarre la session, ouvre le PDO (db.php ← config.php ← variables
d'environnement PTA_DB_*) et appelle SchemaModel::assertCurrent() à chaque requête. Ajouter
une table ou une colonne obligatoire impose de mettre à jour les listes de SchemaModel, sinon
l'application entière refuse de démarrer.
Rôles et périmètres (app/Core/Access.php)
Trois rôles stockés en session : AGENT (lecture seule sur soi), RESPONSABLE_STRUCTURE (édition
des brouillons de son seul lieu), SERVICE_ENFANCE (tout, seul habilité à valider).
role.php est un sélecteur de rôle, pas une authentification — l'utilisateur choisit librement
son identité. C'est la faille ouverte la plus importante du projet ; toute évolution vers la
production doit remplacer cette sélection par un compte authentifié, sans toucher au reste de la
mécanique de droits.
Access.php ne contient aucune règle de droit : tout est lu dans les vues SQL créées par
database/migration_v7_8_securite_acces.sql (voir README_V7_8.md). Modifier un droit se fait en
SQL dans acces_role_page / acces_role_permission, sans redéploiement. Une permission absente de
la base vaut « refusé ». SchemaModel vérifie la présence des sept vues au démarrage et fait
échouer l'application si la migration manque — c'est volontairement fail-closed.
Les contrôles sont doublés : l'UI masque, mais le serveur tranche. Toute méthode de contrôleur
commence par un garde requireService(), requireDraftEditor(), requireStructureEdit($id),
requireAgentView($id), requireEntryEdit($id), requirePlanningEdit($id)… Ces gardes émettent
directement un 401/403 JSON et coupent l'exécution. Ne jamais ajouter un endpoint mutant sans
garde ni sans Csrf::assertPayload($payload).
CSRF : le jeton voyage dans le corps JSON (csrf_token), pas dans un en-tête. Côté JS :
csrf_token: window.PTA_CONFIG.csrfToken.
Contraintes de sécurité à respecter
- CSP à nonce (
app/Core/Security.php) :script-src 'self' 'nonce-…', sansunsafe-inline. Tout<script>inline ajouté à une vue doit porternonce="<?= $cspNonce ?>", et aucunonclick=/style=inline ne fonctionnera. PTA.utils.escapeHtmléchappe aussi les guillemets et est donc valable en contexte texte comme en contexte attribut. Ne pas la réimplémenter localement : c'est une variante incomplète qui avait introduit un XSS stocké.- Ordre des blocs nginx : nginx retient la première regex correspondante. Tout nouveau refus
doit être placé avant
location ~ \.php$, sinon il est sans effet. PTA_DEBUGreste à0. Les détails d'exception passent parSecurity::reportThrowable(), qui journalise côté serveur et ne renvoie rien au client.session_start()ne doit jamais être appelé directement : passer parApp\Core\Session::start().
JavaScript
Pas de bundler ni de module ES. Chaque fichier est une IIFE qui lit le global window.PTA publié
par core.js (config, elements, state, shared, utils, modules) et s'enregistre :
PTA.modules.agentOverview = { init, load };
PageController décide quels scripts et quels modules charger (tableaux scripts / modules),
footer.php les injecte, puis page-bootstrap.js appelle init() sur chaque module déclaré et
applique les filtres d'URL (structure_id, agent_id, week, month, focus). Les modules se
rafraîchissent entre eux via l'appel optionnel PTA.modules.autre?.refresh?.({ silent: true }).
Les assets sont versionnés à la main : assets/style.css?v=7.7 et assets/js/*.js?v=7.7 dans
layouts/header.php / layouts/footer.php. Bumper ce numéro après toute modification de JS/CSS,
sinon les navigateurs et le expires 7d de nginx servent l'ancienne version.
Modèle métier
- Le quota annuel suit l'année civile (01/01 → 31/12), jamais l'année scolaire ni la date de
contrat : 1 607 h de référence proratisées par la quotité (
QuotaModel::REFERENCE_MINUTES). - Un
planning= un agent × une semaine ISO × un lieu, statutBROUILLONouVALIDE. Il contient descreneau_horaireporteurs d'unmotif_planning. Le flagcompte_dans_quotadu motif décide de l'imputation (CONGE, AUTRE_ABSENCE et JNT ne comptent pas). - Un agent peut être planifié sur plusieurs lieux la même semaine ;
id_structuresur l'agent n'est qu'un rattachement par défaut. D'où les contrôles de chevauchement inter-structures. - Les vacances scolaires (
periode_vacance) déterminent le contexte périscolaire/extrascolaire ; elles sont importées depuis l'API publique data.education.gouv.fr viaSchoolHolidayApiService(le conteneur php a donc besoin d'un accès sortant).
Piège : logique de validation dupliquée
database/pta_prod_schema_mariadb.sql définit des vues (v_suivi_quota_annuel,
v_plannings_en_attente_validation, …) et des procédures (sp_controler_planning,
sp_valider_planning) — aucune n'est appelée par le code PHP. Les contrôles réellement
appliqués vivent dans PlanningController::validate(), AnnualOverviewController::validateYear()
et PtaModel. Modifier une règle métier impose de traiter le PHP ; le SQL correspondant est du
patrimoine à ne pas prendre pour la source de vérité.
Les contrôles renvoyés au client suivent partout la forme {niveau: ERREUR|ALERTE|INFO, code, message} : une ERREUR bloque (422), une ALERTE demande une confirmation explicite
(409 + rappel avec force_quota: true).
Points d'attention
- nginx interdit l'accès direct à
/app/,/database/,/docker/, aux fichiers d'amorçage et aux extensions de configuration (docker/nginx/default.conf) — ces règles doivent suivre toute réorganisation de l'arborescence. - phpMyAdmin n'écoute que sur
127.0.0.1et exige un identifiant MariaDB. Ne jamais redéfinirPMA_USER/PMA_PASSWORDdans le compose : cela rouvre une session sans mot de passe. - Le mot de passe MariaDB du
.envest encore la valeur d'exemple : à changer avant tout déploiement réel. - Les
README_V7_*.mddocumentent chacun une version successive et servent d'historique métier — utiles pour comprendre une décision, mais seul le code fait foi.