# PTA — Planning de Travail Annualisé Documentation technique de l'application. Elle s'adresse aux développeurs qui reprennent le projet : elle explique ce que fait l'application, comment elle est construite, et documente l'intégralité des points d'entrée HTTP. > Les fichiers `README_V7_*.md` de ce dossier documentent chacun **une version successive** et > servent d'historique des décisions. Ce document-ci décrit l'**état courant** du code. En cas de > divergence, seul le code fait foi. --- ## Sommaire 1. [Ce que fait l'application](#1-ce-que-fait-lapplication) 2. [Contraintes techniques structurantes](#2-contraintes-techniques-structurantes) 3. [Démarrage](#3-démarrage) 4. [Architecture](#4-architecture) 5. [Rôles, authentification et droits](#5-rôles-authentification-et-droits) 6. [Journalisation](#6-journalisation) 7. [Sécurité — contraintes à respecter](#7-sécurité--contraintes-à-respecter) 8. [JavaScript](#8-javascript) 9. [Modèle métier](#9-modèle-métier) 10. [Piège : logique de validation dupliquée](#10-️-piège--logique-de-validation-dupliquée) 11. [Les pages](#11-les-pages) 12. [**Référence des endpoints API**](#12-référence-des-endpoints-api) · [Planning](#121-planning--saisie-et-validation) · [Semaines types](#122-semaines-types) · [Agents](#123-agents) · [PDF](#124-pdf) · [Lieux & couverture](#125-lieux-daffectation-et-couverture) · [Annuel & PTA](#126-vue-annuelle-et-pta) · [Équipes](#127-équipes) · [Vacances](#128-vacances-scolaires) · [Tableau de bord](#129-tableau-de-bord-et-recherche-dadresse) · [Comptes délégués](#1210-comptes-daccès-délégués--service-enfance-v727) · [Espace DSI](#1211-espace-dsi--comptes-privilégiés-et-journal-v711) 13. [Base de données](#13-base-de-données) 14. [Où chercher quoi](#14-où-chercher-quoi) 15. [Checklist avant commit](#15-checklist-avant-commit) 16. [Historique des versions](#16-historique-des-versions) --- ## 1. Ce que fait l'application PTA gère la **planification annualisée** des agents d'accueils de loisirs périscolaires et extrascolaires d'une collectivité. Le principe métier : un agent ne travaille pas un nombre d'heures fixe chaque semaine, mais un **volume annuel** (1 607 h de référence, proratisé par sa quotité de travail). Les semaines sont donc inégales — beaucoup d'heures pendant les vacances scolaires, moins pendant le temps scolaire — et l'application sert à composer ces semaines, à vérifier en continu que le cumul reste cohérent avec le volume dû, et à valider le résultat. L'application permet de : - saisir semaine par semaine les créneaux d'un agent sur un lieu (`planning.php`) ; - composer des **semaines types** réutilisables et les appliquer sur toute une période ; - suivre le **quota annuel** consommé, mois par mois et sur l'année (`annual.php`, `pta.php`) ; - contrôler la **couverture** : les plages horaires d'un lieu où il manque des agents ; - faire **valider** les plannings par le Service Enfance, avec des contrôles bloquants ; - composer les **équipes** des mercredis et des vacances, avec taux d'encadrement ; - importer le calendrier des **vacances scolaires** depuis l'API officielle ; - éditer des **PDF** (planning d'agent, planning de lieu, bilan d'heures, contrat horaire) ; - administrer les **comptes d'accès** (jetons) et consulter le **journal d'activité**. --- ## 2. Contraintes techniques structurantes **Aucune dépendance externe.** Pas de Composer, pas de npm, pas de framework, pas de bundler, pas de suite de tests. Uniquement **PHP 8.3 + PDO/MariaDB + JavaScript vanilla**. Le générateur PDF (`app/Services/SimplePdf.php`) et l'autoloader (`app/autoload.php`) sont volontairement réécrits à la main. N'introduisez pas de dépendance sans raison forte. **Tout est en français** : identifiants SQL (`creneau_horaire`, `id_agent`, `statut`), messages utilisateur, commentaires, noms de méthodes métier. Conservez cette langue pour tout nouveau code. **Pas de routeur.** Chaque URL correspond à un fichier PHP réel de 5 à 10 lignes, à la racine ou dans `api/`. --- ## 3. Démarrage ```bash cp .env.example .env # puis renseigner DB_PASSWORD / DB_ROOT_PASSWORD docker compose up -d --build ``` L'application est alors sur `http://localhost:8080`, phpMyAdmin sur `http://127.0.0.1:8081`. ### Services docker-compose | Service | Rôle | |--------------|------| | `db` | MariaDB 11.4. Le schéma `database/pta_prod_schema_mariadb.sql` n'est joué qu'à la **création initiale du volume**. | | `db-migrate` | Conteneur jetable qui applique les `database/migration_*.sql` à chaque démarrage. | | `php` | php-fpm 8.3. **Attend la sortie en succès de `db-migrate`** : une migration en échec empêche l'application de tourner sur une base incomplète. | | `nginx` | Sert les pages PHP et les assets statiques. Publié sur `APP_PORT`. | | `phpmyadmin` | N'écoute que sur `127.0.0.1` et exige un identifiant MariaDB. | ### ⚠️ Le code est COPIÉ dans l'image, 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` : ```bash docker compose up -d --build php nginx ``` ### Commandes utiles ```bash docker compose logs -f php nginx db docker compose logs db-migrate # compte rendu de l'application des migrations docker compose exec db mariadb -upta -p pta docker compose down # arrêt en conservant les données docker compose down -v # DÉTRUIT le volume MariaDB # Vérification syntaxique (il n'existe ni linter ni tests automatisés) find . -name '*.php' -not -path './.git/*' -exec php -l {} \; ``` ### Après une installation neuve 1. Se connecter avec le **jeton DSI d'installation** `PTA-INSTALLATION-DSI`, écrit en clair dans `database/migration_v7_9e_dsi_comptes_journal.sql` — il est donc **public**. 2. Le régénérer immédiatement depuis la page « Accès & journaux ». L'écran alerte tant que ce n'est pas fait. 3. Générer les jetons manquants pour équiper les agents et les responsables de lieu. 4. Changer le mot de passe MariaDB du `.env`, encore à sa valeur d'exemple. ### Migrations Les fichiers `database/migration_*.sql` sont appliqués automatiquement par `db-migrate` (`docker/db/apply-migrations.sh`), sur base neuve comme sur base existante. Le script tient un registre `schema_migration` (fichier + empreinte MD5) : un fichier déjà appliqué et inchangé est ignoré, un fichier modifié est **rejoué**. Deux règles : - **Toute migration doit être 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 les migrations récentes suivent le préfixe de la précédente (`…_v7_9e_`, `…_v7_9f_`, `…_v7_9g_`, `…_v7_9h_`) et non 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. --- ## 4. Architecture ### 4.1 Arborescence ``` / points d'entrée « pages » (planning.php, agents.php, …) ├── api/ points d'entrée JSON et PDF ├── app/ │ ├── autoload.php autoloader PSR-4 maison │ ├── bootstrap.php session + PDO + contrôle de schéma + journal │ ├── Controllers/ logique applicative, gardes, validation │ ├── Core/ Access, Csrf, Journal, JsonResponse, Request, Security, Session… │ ├── Models/ SQL PDO uniquement, aucune règle d'accès │ ├── Services/ appels d'API externes et génération PDF │ └── Views/ │ ├── layouts/ header.php (nav) et footer.php (injection JS) │ ├── pages/ une vue par page │ └── partials/ markup réutilisable ├── assets/ style.css et js/ (une IIFE par module) ├── database/ schéma initial + migrations ├── docker/ Dockerfiles, conf nginx, script de migration └── docs/ cette documentation ``` ### 4.2 Deux types de points d'entrée **Pages HTML** — fichiers à la racine : ```php require __DIR__ . '/page_bootstrap.php'; runPage('planning'); // -> PageController::planning() ``` `page_bootstrap.php` attrape tout `Throwable` et rend une page d'erreur HTML autonome ; le détail technique reste journalisé côté serveur par `Security::reportThrowable()`. **Endpoints JSON/PDF** — `api/*.php`, plus `save_planning.php`, `validate_planning.php` et `reopen_planning.php` 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()` et sortent leurs erreurs en texte brut. ### 4.3 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 (window.PTA_CONFIG + scripts) ``` `app/bootstrap.php` démarre la session, ouvre le PDO (`db.php` ← `config.php` ← variables d'environnement `PTA_DB_*`), appelle `SchemaModel::assertCurrent()` **à chaque requête** et pose la connexion du journal. > **Ajouter une table ou une colonne obligatoire impose de mettre à jour les listes de > `SchemaModel`**, sinon l'application entière refuse de démarrer — ou, pire, démarre sans le > contrôle. ### 4.4 Ajouter un endpoint 1. Créer le fichier mince dans `api/` (voir modèle ci-dessus). 2. Ajouter la méthode au contrôleur, avec **dans cet ordre** : garde `Access` → `Request::requireMethod('POST')` → `Request::json()` → `Csrf::assertPayload($payload)` → validation → modèle → `Journal::ecriture()` → `JsonResponse::send()`. ### 4.5 Ajouter une page 1. Fichier PHP à la racine (`require page_bootstrap.php` + `runPage('cle')`). 2. Méthode dans `PageController` — **son nom EST la clé de page** utilisée par `acces_role_page` (d'où `manage_agents()` en snake_case, à rebours du reste du projet). 3. Vue `app/Views/pages/cle.php` + partiel `app/Views/partials/cle.php`. 4. Module JS, déclaré dans les tableaux `scripts` / `modules` de la méthode. 5. Inscription de la clé dans `$navigation` **et** dans un groupe de `$navigationGroups` (`app/Views/layouts/header.php`). 6. **Migration SQL** insérant la ligne dans `acces_role_page` : les pages autorisées sont lues en base, pas écrites en PHP. Une page absente des groupes reste affichée dans un groupe « Autres » : ce filet évite qu'un oubli la rende inatteignable. **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. L'omettre de `$navigation` est ce qui l'empêche d'être aussi repêchée par « Autres » et d'apparaître deux fois. Elle reste soumise à `acces_role_page` (`migration_v7_9d_faq.sql`). --- ## 5. Rôles, authentification et droits ### 5.1 Les quatre rôles | Rôle | Périmètre | |------|-----------| | `AGENT` | Lecture seule sur son propre planning. | | `RESPONSABLE_STRUCTURE` | Édition des brouillons de **son seul lieu**. | | `SERVICE_ENFANCE` | Tout le métier ; **seul habilité à valider**. Distribue les jetons de terrain. | | `DSI` | Administration technique : comptes d'accès et journal. **Aucun droit métier.** | ### 5.2 Authentification par jeton (V7.11) `role.php` n'est **pas** un sélecteur de rôle. 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. La base ne conserve qu'une empreinte SHA-256 du jeton. `Access::select()` a disparu — **ne pas le réintroduire.** ### 5.3 Second facteur pour les comptes privilégiés (V7.12) Les comptes `SERVICE_ENFANCE` et `DSI` exigent, après le jeton, un **mot de passe** choisi par le titulaire à sa première connexion et haché par `password_hash()`. 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 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. La DSI peut **effacer** un mot de passe, jamais en **poser** un — sans quoi elle connaîtrait celui de tout le monde. Un frein anti-force-brute compte les échecs récents via `JournalModel::echecsRecents()` : 10 échecs sur 15 minutes bloquent. **Renommer les actions `JETON_REFUSE` ou `MOT_DE_PASSE_REFUSE` désarmerait ce frein silencieusement.** ### 5.4 Délégation des jetons de terrain (V7.27) 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. ### 5.5 Relecture du compte à chaque requête `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.** ### 5.6 Les droits vivent en SQL, pas en PHP **`Access.php` ne contient aucune règle de droit.** Tout est lu dans les vues créées par `database/migration_v7_8_securite_acces.sql` : `v_acces_role_page`, `v_acces_role_permission`, `v_acces_identite_agent`, `v_acces_identite_structure`, `v_acces_agent_par_structure`, `v_acces_perimetre_creneau`, `v_acces_perimetre_planning` — plus `v_acces_compte`, `v_acces_compte_manquant` et `v_journal_activite` (V7.11). 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 au démarrage la présence des dix vues 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. Permissions actuelles : | Permission | Ce qu'elle ouvre | |------------|------------------| | `read_only` | Marque un rôle en lecture seule (AGENT). | | `can_edit_draft` | Éditer les brouillons — garde `requireDraftEditor()`. | | `can_edit_cross_structure` | Sortir du périmètre d'un seul lieu. | | `can_validate` | Valider un planning. | | `can_reopen` | Repasser un planning validé en brouillon. | | `can_view_pending` | Voir la file d'attente de validation. | | `can_use_templates` | Semaines types (conditionne aussi le chargement des scripts). | | `can_copy_full_week` | Recopier une semaine entière d'un agent. | | `can_forecast_next_year` | Projeter sur l'année suivante. | | `can_manage_agents` | Référentiel des agents. | | `can_manage_structures` | Référentiel des lieux. | | `can_manage_coverage` | Règles de couverture. | | `can_manage_teams` | Équipes des mercredis et vacances. | | `can_manage_vacations` | Calendrier des vacances scolaires. | | `can_manage_pta` | Contraintes et souhaits du PTA. | | `can_manage_access` | Comptes d'accès, tous rôles (DSI). | | `can_manage_field_access` | Comptes d'accès **de terrain uniquement** (Service Enfance, V7.27). | | `can_read_journal` | Journal d'activité (DSI). | ### 5.7 Les gardes de contrôleur Les contrôles sont **doublés** : l'UI masque, mais le serveur tranche. Toute méthode de contrôleur commence par un garde. Ces gardes émettent directement un 401/403 JSON et **coupent l'exécution** — l'appelant n'a donc pas à tester leur retour. | Garde | Ce qu'il vérifie | |-------|------------------| | `requirePage($cle)` | Page autorisée pour le rôle (`acces_role_page`). Pages HTML uniquement. | | `requireRoles([…])` | Appartenance à une liste de rôles. | | `requireService()` | Rôle `SERVICE_ENFANCE`. | | `requireDsi()` | Rôle `DSI`. | | `requireDraftEditor()` | Permission `can_edit_draft`. | | `requireStructureView($id)` | Droit de **consulter** ce lieu. | | `requireStructureEdit($id)` | Droit d'**éditer** ce lieu (un responsable est borné au sien). | | `requireAgentView($id)` | Droit de consulter cet agent (un AGENT est borné à lui-même). | | `requireEntryEdit($id)` | Droit d'éditer ce créneau (`v_acces_perimetre_creneau`). | | `requirePlanningEdit($id)` | Droit d'éditer ce planning (`v_acces_perimetre_planning`). | **Ne jamais ajouter un endpoint mutant sans garde ni sans `Csrf::assertPayload($payload)`.** ### 5.8 CSRF Le jeton voyage **dans le corps JSON** (`csrf_token`), pas dans un en-tête — ce choix évite la requête préliminaire CORS et garde les appels `fetch()` identiques d'un module à l'autre. ```js csrf_token: window.PTA_CONFIG.csrfToken ``` Il vit aussi longtemps que la session : il n'est pas renouvelé à chaque requête, sans quoi deux onglets ouverts s'invalideraient l'un l'autre. La comparaison passe par `hash_equals()`. `role.php` fait exception et vérifie son CSRF à la main : les données y arrivent en formulaire classique et un échec doit produire une page HTML, pas du JSON. --- ## 6. Journalisation `App\Core\Journal` écrit dans `journal_activite`. Sa connexion PDO est posée une fois par `app/bootstrap.php` — les appelants n'ont rien à transporter : ```php Journal::ecriture('PLANNING_VALIDE', 'planning', $planningId, 'Planning validé pour…', [contexte]); ``` Cinq catégories : `CONNEXION`, `ECRITURE`, `ACCES_REFUSE`, `ADMINISTRATION`, `SYSTEME`. **Trois règles :** 1. **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()`. 2. **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. 3. **Le journal ne doit jamais faire échouer l'action** : `Journal` avale ses propres erreurs. Ne pas entourer un appel de logique qui pourrait lever. Actions existantes (extrait) : `CONNEXION_REUSSIE`, `JETON_REFUSE`, `MOT_DE_PASSE_REFUSE`, `CONNEXION_BLOQUEE`, `DECONNEXION`, `COMPTE_CREE`, `JETON_REGENERE`, `JETONS_GENERES_EN_LOT`, `MOT_DE_PASSE_REINITIALISE`, `JOURNAL_PURGE`, `PLANNING_ENREGISTRE`, `PLANNING_VALIDE`, `PLANNING_ROUVERT`, `CRENEAU_MODIFIE`, `CRENEAU_SUPPRIME`, `SEMAINE_COPIEE`, `SEMAINE_TYPE_APPLIQUEE_PERIODE`, `AGENT_CREE`, `AGENT_MODIFIE`, `STRUCTURE_CREEE`, `COUVERTURE_ENREGISTREE`, `VACANCES_ENREGISTREES`, `EQUIPE_CREEE`, `CSRF_INVALIDE`, `ROLE_HORS_PERIMETRE`, `COMPTE_HORS_PERIMETRE`. --- ## 7. Sécurité — contraintes à respecter - **CSP à nonce** (`app/Core/Security.php`) : `script-src 'self' 'nonce-…'`, sans `unsafe-inline`. Tout `