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

303 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 `<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 :
```js
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.