# 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 ```bash # 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` : ```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) : ```php 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 : ```php 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 `