Files
pta/CLAUDE.md
PTA 4201894e28 V7.10 — Tableau de bord, guide, semaine type et enveloppe de formation
Reprise dans le répertoire de production du travail mené dans le worktree
ux-service-enfance :

- tableau de bord Service Enfance (dashboard.php) et navigation regroupée ;
- guide d'utilisation (faq.php) accessible depuis toute la navigation ;
- éditeur de semaine type mis en avant sur l'écran de saisie ;
- droit à l'enveloppe de formation par agent, en remplacement de la case
  « formation déjà prévue dans la répartition annuelle » ;
- correctif de « [object Object] » sur les cartes de quota du PTA ;
- motif de travail par défaut à la saisie, heures travaillées complètes
  dans les PDF.

Le docker-compose.yml de production est conservé : celui du worktree décrit
une pile de test (conteneurs et volume suffixés « test », ports 9080/9081),
qui aurait fait redémarrer l'application sur un volume MariaDB vide.

Les migrations database/migration_v7_9*.sql sont obligatoires ; le service
db-migrate les applique au déploiement.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 16:53:04 +02:00

14 KiB
Raw 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_….

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, 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 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)

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.

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(), 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-…', 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.9 et assets/js/*.js?v=7.9 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 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 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.
  • 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.