17 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 logs db-migrate # compte rendu de l'application des migrations
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) : il ne contient que le socle métier, et aucune des tables
ou vues ajoutées par les migrations.
Les fichiers database/migration_*.sql sont appliqués automatiquement au démarrage par le service
jetable db-migrate (docker/db/apply-migrations.sh), sur base neuve comme sur base existante.
Le service php attend sa sortie en succès : une migration en échec empêche l'application de
tourner sur une base incomplète, plutôt que de la laisser renvoyer une erreur 500 à l'accueil.
Le script tient un registre schema_migration (fichier + empreinte MD5) : un fichier déjà appliqué
et inchangé est ignoré, un fichier modifié est rejoué. Toute migration doit donc rester
idempotente (CREATE TABLE IF NOT EXISTS, CREATE OR REPLACE VIEW,
INSERT … ON DUPLICATE KEY UPDATE). Les fichiers sont pris dans l'ordre alphabétique : nommer une
migration migration_v7_10_… la ferait passer avant migration_v7_9_…. C'est pourquoi la
migration de la V7.11 s'appelle migration_v7_9e_dsi_comptes_journal.sql : le préfixe suit la
dernière migration en date, pas le numéro de version fonctionnelle.
PTA_DB_PORT, DB_PORT et PMA_PORT du compose sont les ports joints à l'intérieur du réseau
Docker, où MariaDB écoute sur 3306. Ce ne sont pas les ports publiés sur l'hôte (APP_PORT,
PMA_PORT de .env) : les modifier coupe l'accès à la base.
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 — dashboard.php, planning.php, agents.php, annual.php, pta.php, teams.php,
coverage.php, structures.php, pending.php, vacations.php, manage_agents.php,
manage_structures.php, administration.php, dsi.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 le tableau $navigation de
app/Views/layouts/header.php, dans un groupe de $navigationGroups, et dans acces_role_page par
une migration SQL (les pages autorisées sont lues en base, pas écrites en PHP — voir plus bas).
Une page absente des groupes reste affichée dans un groupe « Autres » : le filet évite qu'un oubli
la rende inatteignable. Chaque page passe aussi un pageSubtitle décrivant l'action de l'écran.
Seule exception assumée : faq (guide d'utilisation) est volontairement absente de $navigation
et rendue par le bloc .view-tab-help ancré à droite de la barre, sous condition $showHelpTab.
L'omettre de $navigation est ce qui l'empêche d'être aussi repêchée par le filet « Autres » et
d'apparaître deux fois. Elle reste soumise à acces_role_page
(database/migration_v7_9d_faq.sql, les trois rôles) : sans migration, ni bouton ni page.
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)
Quatre rôles stockés en session : AGENT (lecture seule sur soi), RESPONSABLE_STRUCTURE (édition
des brouillons de son seul lieu), SERVICE_ENFANCE (tout le métier, seul habilité à valider) et
DSI (administration technique : comptes d'accès et journal, aucun droit métier).
role.php n'est plus un sélecteur depuis la V7.11 : c'est une authentification par jeton (voir
README_V7_11.md). Une session ne peut naître que de Access::openSession(), appelée après
vérification d'un jeton par CompteModel::authentifier(). Le rôle, l'agent et le lieu sont lus sur
le compte, jamais déclarés par l'utilisateur. Access::select() a disparu — ne pas le
réintroduire.
Les comptes SERVICE_ENFANCE et DSI exigent un second facteur depuis la V7.12 (voir
README_V7_12.md) : après le jeton, un mot de passe choisi par le titulaire à sa première connexion
et haché par password_hash(). La règle « qui doit avoir un mot de passe » est portée par la vue
(v_acces_compte.mot_de_passe_requis), pas par du PHP. Entre les deux étapes, la session ne porte
qu'une attente horodatée de cinq minutes : un jeton validé sans mot de passe n'ouvre rien. La DSI
peut effacer un mot de passe, jamais en poser un — sans quoi elle connaîtrait celui de tout le monde.
Access relit le compte à chaque requête (v_acces_compte, filtrée sur actif et
identite_active) : c'est ce qui fait qu'un jeton révoqué, un agent désactivé ou un lieu fermé
referment la session en cours. Ne pas mettre ce résultat en cache entre deux requêtes.
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é » (les lignes du rôle DSI viennent, elles, de
migration_v7_9e_dsi_comptes_journal.sql). SchemaModel vérifie au démarrage la présence des sept
vues de droits et des trois vues de comptes/journal, et fait échouer l'application si une migration
manque — c'est volontairement fail-closed, et docker/db/apply-migrations.sh refait le même contrôle
au déploiement.
dashboard.php (Service Enfance uniquement, voir README_V7_9.md) illustre la règle : le droit
d'accès vient de database/migration_v7_9_tableau_de_bord.sql. Tant que la migration n'est pas
jouée, l'onglet n'apparaît pas et Access::homeUrl() se replie sur planning.php — ce repli est
indispensable, sans lui requirePage() renverrait indéfiniment vers une page interdite.
Les contrôles sont doublés : l'UI masque, mais le serveur tranche. Toute méthode de contrôleur
commence par un garde requireService(), requireDsi(), 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.
Journalisation (V7.11)
App\Core\Journal écrit dans journal_activite. Sa connexion PDO est posée une fois par
app/bootstrap.php, les appelants n'ont donc rien à transporter :
Journal::ecriture('PLANNING_VALIDE', 'planning', $planningId, 'Planning validé pour…', [contexte]);
Trois règles :
- tout endpoint mutant doit journaliser son succès, après l'écriture en base et avant le
JsonResponse::send()— au même titre qu'il porte un garde et unCsrf::assertPayload(); - les lectures ne sont jamais journalisées (choix de volume) ; les refus le sont, et
AccesscommeCsrfs'en chargent déjà — inutile de les doubler dans les contrôleurs ; - le journal ne doit jamais faire échouer l'action :
Journalavale ses propres erreurs. Ne pas entourer un appel de logique qui pourrait lever.
Le journal sert aussi de compteur au frein anti-force-brute de la connexion
(JournalModel::echecsRecents()) : renommer les actions JETON_REFUSE ou MOT_DE_PASSE_REFUSE
désarmerait ce frein silencieusement.
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.23 et assets/js/*.js?v=7.23 dans
layouts/header.php / layouts/footer.php et dans Views/pages/role.php, qui porte sa propre
balise <head>. Bumper ce numéro aux trois endroits 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). - Les 1 607 h sont un volume NET de travail effectif :
365 - 104 (week-ends) - 25 (congés payés) - 8 (jours fériés) = 228 j × 7 h = 1 596 h, arrondies à 1 600 h, plus les 7 h de la journée de solidarité. Congés payés et jours fériés en sont donc déjà retranchés : leur imputer des heures de quota les décompterait une seconde fois. Une tentative de décompter un forfait de 7 h par jour de CP a été introduite puis annulée pour cette raison — ne pas la réintroduire. - Un agent n'est planifiable qu'entre ses dates de contrat (V7.15,
date_debut_contrat/date_fin_contrat). La règle vaut pour TOUS les motifs, congés compris : hors contrat, il n'y a rien à décompter. Une borne absente ouvre — pas de date de fin signifie contrat à durée indéterminée. Elle est appliquée à l'écriture (assertDatesWithinContract()dansPlanningControlleretWeekTemplateController), au contrôle de validation (HORS_CONTRAT) et à l'inverse, sur la fiche agent : raccourcir un contrat sous des affectations existantes est refusé. Toute nouvelle voie d'écriture de créneaux doit poser la même garde. - 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). - Une semaine type (
modele_semaine_agent/modele_semaine_creneau) appartient à un agent et décrit un lundi→vendredi générique. Ce n'est pas un planning : rien n'est planifié tant qu'elle n'est pas appliquée à une semaine ou dupliquée sur une période. Elle se compose soit à partir d'une semaine réellement planifiée (saveFromWeek()), soit directement dans l'éditeur (saveFromEntries(), voirREADME_V7_10.md) — les deux passent parpersistTemplate().
planning.php propose deux modes exclusifs (voir README_V7_10.md) : l'éditeur de semaine type,
ouvert par défaut, et la saisie semaine par semaine. Le second reste la cible de tous les liens
profonds : dès que l'URL porte week ou focus, page-bootstrap.js bascule sur ce mode, sinon un
utilisateur venant corriger un planning identifié atterrirait dans l'éditeur de modèles.
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. - Le jeton DSI d'installation (
PTA-INSTALLATION-DSI) est écrit en clair dansdatabase/migration_v7_9e_dsi_comptes_journal.sql, donc public. La page DSI alerte tant qu'il n'a pas été régénéré ; c'est la première action à faire après une installation. - 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.