Files
pta/CLAUDE.md
2026-08-25 15:12:11 +02:00

18 KiB
Raw Permalink Blame History

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.

Depuis la V7.27 (README_V7_27.md), le Service Enfance distribue lui-même les jetons des rôles AGENT et RESPONSABLE_STRUCTURE, depuis l'onglet « Administration » (AccessAccountController, api/acces_*.php). La délégation s'arrête à ces deux rôles : un compte SERVICE_ENFANCE créé par le Service Enfance serait une auto-promotion, un compte DSI lui ouvrirait le journal de ses propres actions. La borne est posée des deux côtés — AccessAccountController::ROLES_DELEGUES en PHP, can_manage_field_access en base — et ne doit être élargie ni d'un côté ni de l'autre. La DSI reste seule sur les comptes privilégiés, les mots de passe et le journal.

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 un Csrf::assertPayload() ;
  • les lectures ne sont jamais journalisées (choix de volume) ; les refus le sont, et Access comme Csrf s'en chargent déjà — inutile de les doubler dans les contrôleurs ;
  • le journal ne doit jamais faire échouer l'action : Journal avale 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-…', sans unsafe-inline. Tout <script> inline ajouté à une vue doit porter nonce="<?= $cspNonce ?>", et aucun onclick= / 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_DEBUG reste à 0. Les détails d'exception passent par Security::reportThrowable(), qui journalise côté serveur et ne renvoie rien au client.
  • session_start() ne doit jamais être appelé directement : passer par App\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() dans PlanningController et WeekTemplateController), 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, statut BROUILLON ou VALIDE. Il contient des creneau_horaire porteurs d'un motif_planning. Le flag compte_dans_quota du motif décide de l'imputation (CONGE, AUTRE_ABSENCE et JNT ne comptent pas).
  • Un compte d'accès coupé retire l'agent des listes d'AFFECTATION (V7.29) : sélecteur de la saisie (AgentModel::listForLocation()) et composition des équipes (allSelectable()). Un agent sans compte reste proposé — c'est l'état normal avant distribution des jetons, le filtrer viderait les listes d'une installation neuve. Les écrans de consultation et le référentiel « Agents » continuent de le montrer : c'est de là qu'on lui rétablit son accès. allActive() reste la liste non filtrée ; ne pas confondre les deux.
  • Un agent peut être planifié sur plusieurs lieux la même semaine ; id_structure sur 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 via SchoolHolidayApiService (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(), voir README_V7_10.md) — les deux passent par persistTemplate().

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.1 et exige un identifiant MariaDB. Ne jamais redéfinir PMA_USER / PMA_PASSWORD dans le compose : cela rouvre une session sans mot de passe.
  • Le mot de passe MariaDB du .env est encore la valeur d'exemple : à changer avant tout déploiement réel.
  • Le jeton DSI d'installation (PTA-INSTALLATION-DSI) est écrit en clair dans database/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_*.md documentent chacun une version successive et servent d'historique métier — utiles pour comprendre une décision, mais seul le code fait foi.