# Lunycas — réservation d'interventions

Socle applicatif, cinq rôles, gestion des comptes, gestion des biens et
planning des réservations — avec une matrice de permissions explicite et testée.

**PHP 8.2+ et MySQL/MariaDB.** Aucun framework, aucun Composer, aucune
bibliothèque à ce jour — le déploiement est une copie FTP. Le seul JavaScript du
projet est une dizaine de lignes dans la fiche d'un bien, et l'écran fonctionne
sans lui.

## Les rôles et qui peut quoi

**Les cinq rôles se connectent.** `Role::peutSeConnecter()` n'existe plus : elle
aurait renvoyé `true` partout. Ce qui distingue les rôles n'est pas le droit
d'entrer, c'est ce qu'ils trouvent en entrant.

| Rôle | Registre | Biens | Agenda de travail | Référentiel | Crée / modifie / révoque |
|---|---|---|---|---|---|
| Superadmin | tout | tous | tous | oui | Admins, Concierges, Prestataires, Propriétaires |
| Admin | tout sauf lui | tous | tous | oui | Concierges, Prestataires, Propriétaires |
| Concierge | ses rattachés | ceux de **ses** propriétaires | idem | non | Prestataires, Propriétaires |
| Prestataire | — | — | ceux où il **intervient** | non | — |
| Propriétaire | — | — | ceux qu'il **possède** | non | — |

Prestataires et propriétaires n'atteignent que `/planning`, `/profil` et les
**réservations** des logements qui leur sont accessibles. Toute autre URL leur renvoie
403 — la table de routage s'en charge, et un test le vérifie pour chacune.

## Le rattachement à un concierge

Chaque prestataire et chaque propriétaire relève d'un concierge — la colonne
`utilisateurs.concierge_id`, ajoutée par `005_rattachement_concierge.sql`. C'est
ce qui donne son sens au tableau ci-dessus : un concierge ne voit pas « les
prestataires » mais « ses prestataires ».

| Qui | Ce qu'il peut faire du rattachement |
|---|---|
| Superadmin, Admin | le choisit à la création, le consulte et le change à tout moment |
| Concierge | aucun choix : ce qu'il crée lui est rattaché d'office |

**Un concierge ne peut pas rattacher.** Ce n'est pas un oubli : pouvoir réaffecter
reviendrait à se retirer une fiche du périmètre, ou à s'attribuer celle d'un
confrère — donc à contourner l'isolation. Le champ ne lui est pas affiché, et une
valeur forcée dans le POST est ignorée.

Côté administrateur, l'information apparaît à trois endroits, selon l'angle :

- **au registre**, le nom du concierge sous chaque fiche de prestataire ou de
  propriétaire, avec la mention « non rattaché » quand il n'y en a pas ;
- **sur la fiche d'un concierge**, la liste de ce qui relève de lui ;
- **sur la fiche d'un prestataire ou d'un propriétaire**, le sélecteur qui permet
  de le choisir et de le changer.

Ne confondez pas `concierge_id` avec `cree_par` : le premier dit de qui la fiche
relève aujourd'hui et se réaffecte, le second qui l'a saisie et ne change jamais.
Un administrateur peut donc créer un prestataire pour le compte d'un concierge.

`concierge_id` accepte `NULL`. Le refuser interdirait de créer le moindre
prestataire avant d'avoir un concierge, et transformerait une donnée manquante en
blocage. Une fiche non rattachée reste visible des administrateurs, qui peuvent
l'affecter ensuite — le registre la signale comme suivie par personne.

**Révoquer un concierge ne détache pas ses fiches.** Elles resteraient
invisibles à tous les concierges sans que rien ne le dise ; l'application
avertit donc explicitement, et conserve le rattachement pour qu'il reste
réaffectable. Détacher effacerait l'information « de qui cela relevait » sans
rien résoudre.

Deux conséquences à connaître avant de toucher au code.

**Personne ne peut promouvoir.** Aucun rôle ne crée son propre niveau : un admin
ne crée pas d'admin, un concierge ne crée pas de concierge. C'est vérifié de
façon exhaustive par `tests_permissions.php`, pas seulement cas par cas.

**Prestataires et propriétaires sont des comptes en lecture seule.** Ils
reçoivent un lien d'activation comme les autres, ont un mot de passe, peuvent être
verrouillés et déverrouillés — mais n'atteignent que `/planning`, `/profil` et les
réservations de leurs logements.
C'était l'inverse jusqu'à l'itération précédente : ils n'étaient que des fiches de
contact. Le changement a supprimé `Role::peutSeConnecter()` et ses treize points
d'appel.

## Les biens et le planning

Un bien — appartement, maison ou mobil-home — porte son adresse éclatée
(niveau, rue, code postal, ville), une zone de texte libre, une surface, une
capacité, et une liste de pièces.

**Aucune colonne `concierge_id` sur `biens`, volontairement.** Un bien relève de
ses **propriétaires**, et le concierge s'en déduit par leur rattachement
(`utilisateurs.concierge_id`). Dupliquer l'information sur le bien créerait deux
vérités qui divergeraient au premier changement de concierge. Le coût est une
sous-requête de plus à la lecture ; le bénéfice, une seule source.

Conséquences à connaître :

- **un bien sans propriétaire n'est visible d'aucun concierge.** Ce n'est pas un
  bogue mais la règle qui se manifeste ; le registre le signale explicitement ;
- **un bien détenu par deux propriétaires rattachés à deux concierges est visible
  des deux.** Il relève réellement des deux.

### La troisième dimension de périmètre

[PorteeBiens](src/Domain/PorteeBiens.php) répond à « quels biens » comme
`Role::gere()` répond à « quels rôles ». Chaque rôle emprunte **un seul** chemin,
et un test vérifie qu'aucun n'en cumule deux — cumuler élargirait au lieu de
borner. Le garde-fou décisif : un identifiant absent donne une portée **vide**, et
jamais totale. Sans lui, un état inattendu ouvrirait tous les biens à n'importe qui.

### Pièces et couchages

Les **types de pièce** sont un référentiel en base, configurable par les
administrateurs : « cave à ski » ou « pool house » n'ont pas à passer par une
migration. Un seul drapeau est structurant, `est_chambre` : il commande
l'apparition des champs douche / baignoire / WC et des couchages. Un administrateur
peut donc créer « Mezzanine » ou « Chambre parentale » et obtenir ces champs sans
toucher au code.

Les **types de lit**, eux, sont une énumération fermée
([Couchage](src/Domain/Couchage.php)). Ce n'est pas une incohérence : chaque type
de lit implique une capacité, donc de la logique et une colonne ; un type de pièce
n'implique rien. Ajouter un lit sera une migration, et surtout une décision
explicite.

Un type ne se supprime pas, il se retire des choix. La clé étrangère de `pieces`
est en `RESTRICT` : une suppression échouerait, et proposer un bouton qui échoue
une fois sur deux serait pire que ne pas le proposer.

### Import iCal

Collez l'URL du flux exporté par Airbnb, Booking ou Abritel sur la fiche du bien.
**Import à sens unique** : rien n'est écrit chez la plateforme.

La récupération est faite par `tasks/synchroniser_ical.php`, à déclarer en tâche
planifiée horaire — pas à l'affichage. Aller chercher trois URL distantes à chaque
chargement rendrait le planning lent et dépendant de la disponibilité d'Airbnb. Un
bouton « synchroniser maintenant » double la tâche pour vérifier une URL qu'on
vient de coller.

L'analyseur ([Ical](src/Support/Ical.php)) est volontairement partiel : il traite
ce que publient les plateformes et ignore les récurrences, les fuseaux et les
alarmes. Deux pièges du format sont en revanche traités et ne sont pas optionnels
— le **pliage des lignes** (au-delà de 75 octets, la suite est préfixée d'une
espace) et le **point-virgule des paramètres** (`DTSTART;VALUE=DATE:20260714`).
`tests_ical.php` couvre les quatorze cas qui cassent en pratique.

**Convention de dates, partout dans le code :** `fin` est la date de **départ**,
exclusive, comme dans la norme iCal. Un séjour du 3 au 5 occupe les nuits du 3 et
du 4 ; le bien est libre le 5. C'est ce que publient les plateformes, et s'en
écarter décalerait tout d'un jour. L'assertion la plus importante de
`tests_calendrier.php` vérifie précisément qu'un départ et une arrivée le même jour
ne se chevauchent pas.

### Le calendrier

Rendu par le serveur, en trois échelles — semaine, mois, trimestre — sans
bibliothèque JavaScript. Le même moteur ([Calendrier](src/Support/Calendrier.php)) sert
les deux écrans : les réservations d'un logement et l'agenda de travail. Une bibliothèque de calendrier vaut son poids quand on
déplace des événements à la souris ; pour de la **consultation**, elle n'ajoute
qu'un téléchargement et une dépendance. La navigation passe par des liens, donc
l'historique, les favoris et l'ouverture dans un nouvel onglet fonctionnent sans
code supplémentaire. Le seul JavaScript du projet est une dizaine de lignes qui
masquent le bloc « chambre » quand le type choisi n'en est pas une — et sans lui,
le bloc reste simplement visible.

## Deux calendriers, pas un

C'est la correction de conception la plus importante du projet, et elle explique le
découpage des écrans.

| Écran | Question | Axe |
|---|---|---|
| `/biens/{ref}/reservations` | Quand ce logement est-il occupé ? | un logement, des nuitées |
| `/planning` | Qu'ai-je à faire cette semaine, moi et mes équipes ? | tous les logements, du travail |

Les deux tenaient sur le même écran. Un concierge devait choisir un logement pour voir
son travail, et y voyait des nuitées qui ne le concernaient pas. **`/planning` n'affiche
plus aucune réservation.** Il reste ouvert aux cinq rôles, et `PorteeBiens` fait le
reste : le prestataire y voit les biens où il intervient, le propriétaire les siens.

Le concierge garde accès à l'écran des réservations : arbitrer un ménage sans voir le
séjour qui le motive n'aurait pas de sens.

L'écran des réservations est **un calendrier puis une liste**. Un calendrier situe ; il
ne dit pas ce qui est demandé. La liste détaille chaque séjour — dates, nuits, source —
et sous lui les prestations avec leur date d'intervention, leur état et leur intervenant.
C'est de là que les prestations se demandent, parce que c'est là qu'on a le séjour sous
les yeux. C'est aussi la seule porte d'entrée d'un propriétaire, qui n'atteint pas
`/biens` : le planning liste donc ses logements en lien direct.

## Une intervention naît d'un séjour

**`interventions.reservation_id` est `NOT NULL`.** Un lien optionnel aurait laissé
coexister deux espèces d'interventions — celles qu'une annulation remet en cause et les
autres — pour une économie de raisonnement nulle. La contrepartie est assumée : un bien
sans flux iCal n'aurait rien de demandable, d'où **la saisie manuelle d'un séjour**, sur
le même écran.

### Le moment fait le nombre d'interventions

Chaque prestation du catalogue porte son moment dans le séjour
([MomentPrestation](src/Domain/MomentPrestation.php)) : à l'arrivée, au départ, arrivée
**et** départ, ou libre. « Location de draps » = arrivée et départ produit **deux**
interventions — la pose puis la reprise — et « Ménage » = départ en produit une, le jour
du départ. La date de chacune se déduit du séjour, sans saisie ; seul un moment « libre »
la fait choisir, bornée au séjour.

Porté par le **type** et non par le tarif : « les draps se posent à l'arrivée » est une
propriété du métier, la même dans un studio et dans un chalet. Le tarif porte ce qui
varie d'un logement à l'autre, le prix et la durée.

Une unicité `(reservation_id, type_prestation_id, moment)` interdit la double demande.
Le formulaire l'anticipe : une occurrence déjà demandée s'affiche en lecture seule
plutôt que d'être refusée après envoi.

### Ce qui se passe quand un séjour bouge

**La synchronisation iCal ne remplace plus, elle rapproche.**
[rapprocherReservations()](src/Repository/PlanningRepository.php) distingue quatre issues
par séjour : créé, déplacé, inchangé, annulé. C'était un blocage réel : l'ancienne
version effaçait puis réinsérait toutes les réservations d'une source, donc
`reservations.id` changeait **toutes les heures** au passage du cron, et une clé
étrangère depuis `interventions` aurait été détruite à chaque fois. L'identité stable
existait déjà — la colonne `uid`, avec son unicité `(source, uid)` — elle n'était pas
exploitée.

**Une réservation annulée n'est plus supprimée, elle est marquée.** Les plateformes ne
publient pas d'événement d'annulation : l'événement cesse simplement d'être dans le flux.
Supprimer la ligne arracherait son ancrage à l'intervention, et priverait le concierge de
l'explication.

Quand un séjour est annulé **ou déplacé**, la validation est annulée et l'intervention
repasse « à confirmer », avec la cause dans la colonne `alerte` — personne ne se déplace,
mais la décision reste au concierge, pas au cron. Trois statuts, trois traitements :

- `validee` → `demandee`, plus l'alerte ;
- `demandee` → l'alerte seule, et la date suit le séjour si le moment l'impose ;
- **`terminee` intouchée** — le travail a eu lieu, il est peut-être facturé ; une
  annulation tardive chez la plateforme ne réécrit pas l'histoire. Idem pour `refusee` et
  `annulee`.

L'heure, la durée et l'intervenant sont **conservés**. Ils ne valent plus engagement une
fois le statut revenu à « demandée » — l'agenda ne lit que `validee` et `terminee` — mais
ils pré-remplissent la re-validation, ce qui évite de tout rechoisir pour un séjour
décalé de vingt-quatre heures.

[SynchronisationIcal](src/Support/SynchronisationIcal.php) fait les quatre étapes en un
point : télécharger, rapprocher, remettre en cause, consigner. Les deux appelants — le
cron et le bouton « synchroniser maintenant » — recopiaient la boucle ; avec une étape de
plus, l'un des deux aurait fini par oublier la troisième.

## Le catalogue et les tarifs

Un concierge tient un **catalogue** — ce qu'il sait faire — puis pose un **tarif**
par bien. Les deux sont distincts pour une raison : un ménage ne prend pas le même
temps ni le même prix dans un studio et dans un chalet.

**C'est le tarif qui fait l'offre, pas le catalogue.** Une prestation sans tarif
n'apparaît dans aucun formulaire de demande. Conséquence à connaître : un bien
détenu par deux propriétaires relevant de deux concierges présente les prestations
des **deux** catalogues, et le propriétaire n'y voit qu'une liste — d'où vient une
prestation ne le concerne pas.

### L'ordre du catalogue

Il se règle avec des **flèches haut/bas**, pas dans un champ « ordre » à remplir.
Trois raisons de préférer les flèches au glisser-déposer : celui-ci ne fonctionne
pas du tout sans JavaScript, réclame un équivalent clavier pour rester accessible,
et une requête asynchrone pour enregistrer — trois mécanismes là où deux liens
suffisent.

Un déplacement **renumérote tout le catalogue** par pas de dix, il n'échange pas
deux valeurs. C'est indispensable : l'ancien champ de saisie valait 100 par défaut,
donc plusieurs lignes partagent ce nombre, et échanger deux valeurs égales ne
déplace rien — la flèche semblerait sans effet. La renumérotation répare ces
égalités au passage, sans migration. Le calcul est isolé dans
[Ordre](src/Support/Ordre.php) et vérifié sans base.

Cet ordre est celui qu'on retrouve partout : sur l'écran des tarifs d'un bien, et
dans la liste où un propriétaire choisit sa prestation.

### L'écran des tarifs d'un bien

Un **tableau** qui affiche **toutes les prestations possibles**, tarifées ou non :
celles qui le sont portent leurs valeurs, les autres attendent une ligne vide. Pas
de formulaire d'ajout séparé — tarifer, c'est remplir une ligne déjà présente.

`table-layout:fixed` avec des largeurs déclarées en `<colgroup>` n'est pas un détail
d'esthétique : c'est ce qui garantit qu'une cellule ne peut pas pousser sa voisine.
Les étiquettes d'état sont placées **sous** le libellé, dans sa cellule, pour qu'une
mention « non tarifée » fasse grandir la hauteur de la ligne sans déplacer un seul
champ. Les champs prennent 100 % de leur colonne, donc ils ont la même largeur d'une
ligne à l'autre sans dimension écrite à la main.

**Un seul bouton pour tout le tableau**, parce qu'on corrige plusieurs prix à la
suite. Trois conséquences assumées dans le contrôleur :

- une ligne au **prix effacé retire le tarif** — dans un tableau, une case vide veut
  dire « pas de prix », donc « pas proposé ». Pour retirer une prestation de l'offre
  **sans** perdre son prix, on décoche « proposée » : le chemin non destructeur est
  juste à côté ;
- une ligne **inchangée n'est pas réécrite**, sinon une seule correction toucherait
  `modifie_le` sur vingt lignes et remplirait le journal d'audit d'écritures vides ;
- une **saisie invalide n'annule que sa ligne**, et le message **nomme la prestation**
  en cause. C'était l'objection contre l'envoi groupé — renvoyer tout le tableau en
  erreur pour un prix mal tapé — et la nommer est ce qui la lève.

Une prestation retirée du catalogue mais encore tarifée s'affiche en **lecture
seule**, sans aucun champ. Avec une case à cocher il fallait choisir entre la montrer
cochée, en affirmant qu'elle est proposée alors qu'un type retiré est exclu de
l'offre, ou décochée — et le premier enregistrement aurait alors désactivé le tarif
sans que personne ne l'ait demandé.

Une seule route d'écriture (`POST /biens/{ref}/tarifs`) : poser, réviser et retirer un
prix sont le même geste à l'écran, et trois routes pour un geste divergent tôt ou
tard. La base arbitre les envois simultanés via `ON DUPLICATE KEY UPDATE` sur l'index
unique `(bien_id, type_prestation_id)`. **N'y remplacez pas `VALUES(col)` par la
syntaxe à alias de MySQL 8.0.20** : MariaDB, qui équipe la plupart des mutualisés, ne
la connaît pas.

**Quelles prestations sont « possibles ».** Celles des catalogues des concierges
rattachés au bien, c'est-à-dire les concierges de ses propriétaires — un tarif venu
du catalogue d'un concierge étranger au bien n'aurait personne pour l'exécuter. S'y
ajoutent celles qui ont **déjà** un tarif ici, même retirées du catalogue : sinon un
tarif deviendrait invisible tout en restant en base. Conséquence : un bien sans
propriétaire rattaché n'a aucune ligne, et l'écran dit quoi corriger au lieu
d'afficher un tableau vide.

## Les trois états d'un compte

À ne pas confondre — ils ont des causes et des remèdes différents, et c'est la
source d'erreur la plus probable dans ce code.

| État | Colonne | Cause | Remède |
|---|---|---|---|
| **Actif** | `actif = 1`, `verrouille_le IS NULL` | — | — |
| **Révoqué** | `actif = 0` | décision d'un administrateur | « Réactiver » |
| **Verrouillé** | `verrouille_le IS NOT NULL` | `verrou_max` échecs de mot de passe cumulés | « Déverrouiller », ou « Mot de passe oublié » |

Les deux derniers sont **indépendants** : un compte peut être verrouillé et
actif, révoqué et non verrouillé, ou les deux. « Réactiver » lève les deux à la
fois — dépanner quelqu'un ne doit pas exiger de savoir lequel le bloquait.

Le verrouillage compte les échecs **cumulés depuis la dernière connexion
réussie**, sans fenêtre de temps, contrairement au blocage temporaire de 15
minutes. Une fenêtre glissante permettrait de tenter indéfiniment en espaçant les
essais.

**Ce mécanisme est un vecteur de déni de service, et il faut le savoir.**
Quiconque connaît une adresse peut bloquer son titulaire en saisissant de faux
mots de passe. Trois choses limitent les dégâts :

- `verrou_max` (10 par défaut) est très au-dessus de `echecs_max` (5). Le blocage
  temporaire arrête le bruteforce sans offrir ce pouvoir ; le verrou durable
  n'intervient que bien au-delà ;
- seul un compte **existant et connectable** est verrouillé. Marteler une adresse
  inconnue ne crée rien ;
- « Mot de passe oublié » lève le verrou, donc la situation se répare sans
  intervention. Mettez `verrou_max` à `0` pour désactiver le verrou entièrement.

### Qui déverrouille qui

`Role::deverrouille()` est plus large que `Role::gere()` dans **un seul cas** : un
administrateur peut déverrouiller un autre administrateur, alors qu'il ne peut ni
le modifier ni le révoquer.

Ce n'est pas une entorse mais la lecture exacte de la règle : déverrouiller
n'accorde aucun privilège, cela restaure un état que le compte avait déjà. Sans
cette exception, un administrateur qui se trompe dix fois immobiliserait ses
collègues jusqu'à l'intervention du superadmin. Un test vérifie que l'écart ne va
pas plus loin que ce cas.

Un concierge, lui, ne déverrouille personne en pratique : son périmètre ne
contient que des prestataires et des propriétaires, qui ne se connectent pas et ne
peuvent donc jamais être verrouillés. La commande existe, elle ne s'affiche
jamais.

## Mot de passe oublié

`/mot-de-passe-oublie`, public par nécessité : celui qui en a besoin ne peut pas
se connecter. Le lien part par courriel via `mail()`, et **jamais à l'écran**.

C'est la règle centrale de cet écran. Afficher le lien reviendrait à laisser
n'importe qui prendre la main sur n'importe quel compte en saisissant une
adresse — un contournement complet de l'authentification. C'est ce qui distingue
cet écran de l'action « Réinitialiser » d'un administrateur, où le lien s'affiche
parce que celui qui le voit est déjà authentifié.

Deux conséquences dans le code, qu'il ne faut pas « simplifier » :

- **la réponse est invariable**, que l'adresse corresponde à un compte ou non, et
  même si l'envoi échoue. La faire varier publierait la liste des adresses
  possédant un compte ;
- **les compteurs sont étanches** (`tentatives_connexion.action`) : une demande de
  réinitialisation ne rapproche jamais du verrouillage. Sinon demander de l'aide
  aggraverait la situation.

Configurez `courriel.expediteur` avec une adresse **de votre domaine hébergé** —
un `From` étranger au domaine fait rejeter le message. Sans SPF ni DKIM, comptez
sur une délivrabilité médiocre depuis un mutualisé : une bonne part des messages
part en indésirables. Ce n'est pas un défaut du code, et l'écran des migrations
signale si l'expéditeur n'est pas configuré.

## L'écran des migrations

`/admin/migrations`, réservé au superadmin : état du schéma, application des
migrations en attente, et un récapitulatif de l'environnement (versions, dossier
des migrations, expéditeur des courriels, état de la porte à clé).

**Il ne remplace pas `public/migrer.php` et ne peut pas le remplacer.** Cet écran
exige une session, donc une connexion, donc les tables que la limitation de débit
interroge. Sur une base vide — ou en retard d'une migration touchant
l'authentification — il est inatteignable, c'est-à-dire précisément au moment où
il faudrait migrer. Gardez la porte à clé pour l'amorçage, cet écran pour le
courant.

## Installation en local

```bash
cp config.example.php config.php     # renseignez db et superadmin
php tasks/migrer.php                 # crée les tables
php -S localhost:8000 -t public public/index.php
```

Connectez-vous sur `/connexion` avec l'email et le mot de passe de la section
`superadmin` de `config.php`. Il n'y a pas de script d'amorçage : le premier
compte est celui du fichier de configuration.

## Tests

```bash
php tests_permissions.php     # matrice de permissions et portées, sans base
php tests_prestations.php     # tarifs, unités, centimes, ordre du catalogue
php tests_ical.php            # analyseur iCal, sans base ni réseau
php tests_calendrier.php      # grilles de calendrier, calcul de dates pur
php tests_sql.php             # défauts de requêtes, en lisant le code source
./tests_fonctionnels.sh       # parcours HTTP complets, DÉTRUIT la base
```

Les cinq premières tournent partout, y compris sans MySQL : ces règles sont du
calcul pur et doivent rester vérifiables sur n'importe quel poste.

`tests_permissions.php` couvre 276 assertions. Il insiste sur les **interdits**,
car une permission oubliée se voit à l'usage tandis qu'une permission accordée
par erreur ne se voit jamais. Plusieurs assertions bouclent sur `Role::cases()`
au lieu de citer les rôles un par un : ajouter un rôle sans traiter sa visibilité
fera donc tomber un test, au lieu d'ouvrir une brèche par omission.

Il lit aussi **la table de routage** de `public/index.php` et confronte les rôles
déclarés sur chaque route au prédicat qui gouverne l'affichage du lien
correspondant. Ce contrôle vient d'un vrai bug : `/administration` était ouverte
aux administrateurs seuls alors que le bandeau affichait le lien à tout
concierge, qui recevait donc un 403 sur son propre catalogue de prestations — et
la suite était verte, parce qu'elle ne vérifiait que les prédicats. Une route
ajoutée sans déclarer qui y accède fait maintenant échouer la suite.

`tests_sql.php` lit les requêtes du code source et vérifie qu'aucune ne rejoue un
paramètre nommé. `Db` désactive l'émulation des requêtes préparées : MySQL attend
alors une valeur **par occurrence** de placeholder, donc `:id` écrit deux fois en
réclame deux. Avec l'émulation — le défaut de PDO, actif dans bien des
environnements de développement — le même code passe sans broncher. C'est
exactement ainsi qu'un `SQLSTATE[HY093]` a atteint la production sans jamais se
manifester en local.

`tests_fonctionnels.sh` demande un MariaDB local, détruit la base `conciergerie`
et sauvegarde puis restaure votre `config.php`. À lancer sur un environnement
jetable.

## Déploiement sur OVH mutualisé, sans SSH

### 1. Arborescence

`public/` doit être la racine web, tout le reste au-dessus. Déclarez un
multisite dans l'espace client OVH en indiquant `app/public` comme dossier
racine. Ne déplacez jamais `index.php` vers `www/` : `config.php` deviendrait
téléchargeable, avec vos identifiants de base.

```
/                       (racine FTP)
├── app/
│   ├── config.php      ← hors zone web
│   ├── src/            code applicatif + chargeur de classes
│   ├── views/
│   ├── db/migrations/  fichiers .sql
│   ├── tasks/          scripts CLI
│   ├── storage/logs/   inscriptible
│   └── public/         ← racine du multisite OVH
└── www/                (inutilisé)
```

Il n'y a pas de dossier `vendor/` : le chargeur de classes est dans
`src/autoload.php`. Le transfert FTP porte sur une quarantaine de fichiers,
pas des milliers.

### 2. Version de PHP

Fichier `.ovhconfig` à la racine FTP :

```
app.engine=php
app.engine.version=8.3
http.firewall=none
environment=production
container.image=stable
```

### 3. Configuration

Copiez `config.example.php` en `config.php`, renseignez la base et la section
`superadmin`, puis passez `env` à `production` et `session.secure` à `true`
(obligatoire dès que vous êtes en HTTPS).

Le mot de passe du superadmin est **en clair** dans ce fichier. Traitez-le comme
un secret d'infrastructure et non comme un mot de passe d'utilisateur : le
fichier est hors zone web, mais une sauvegarde FTP le transporte. Il n'y a pas
d'écran pour le changer — on édite le fichier. Douze caractères minimum, sinon la
connexion superadmin reste fermée plutôt que faible.

### 4. Droits

`chmod 755` sur `storage/` et `storage/logs/`. Sans droit d'écriture,
l'application renvoie une 500 opaque : c'est le premier point à vérifier devant
une page blanche.

### 5. Migrations

Trois voies, de la plus pratique à la plus rustique.

**Porte web** — la plus commode pendant le développement. Mettez une chaîne
aléatoire de 32 caractères dans `config.php` :

```php
'migration' => ['cle' => 'collez-ici-32-caracteres-aleatoires'],
```

Appelez `https://votre-domaine/migrer.php?cle=...`. Ajoutez `&etat` pour voir
l'état sans rien appliquer. **Remettez `cle` à `null` ensuite**, ou supprimez
`public/migrer.php`. Sans clé configurée, le fichier renvoie une 404.

**Tâche planifiée** — déclarez `app/tasks/migrer.php` dans l'espace client OVH.
Le compte rendu part dans `storage/logs/migrations.log`, lisible par FTP.
Comptez plusieurs minutes de latence.

**phpMyAdmin** — importez les `.sql` de `db/migrations/` à la main. Fonctionne,
mais la table `migrations` ne sera pas alimentée et vous perdrez le suivi.
À réserver au dépannage.

### 6. Premier compte

Rien à faire : c'est la section `superadmin` de `config.php`. Connectez-vous sur
`/connexion` avec ces identifiants, puis créez les administrateurs et concierges
depuis `/utilisateurs`.

Ce compte n'a **pas de ligne en base**. C'est un choix, avec ses contreparties :

- il existe dès la première requête, sans script d'amorçage ni tâche planifiée ;
- il ne peut être ni révoqué ni modifié depuis l'interface, donc pas supprimé par
  accident ;
- en revanche `Auth::id()` vaut `null` pour lui. `journal_audit.acteur_id` et
  `utilisateurs.cree_par` restent donc `NULL` sur ses actions, et c'est la
  colonne `journal_audit.acteur_libelle` qui porte son identité. Un compte créé
  par le superadmin a bien un `cree_par` vide : ce n'est pas une anomalie.

Son identité est relue dans `config.php` **à chaque requête**. Changer son email
dans le fichier ferme donc immédiatement sa session en cours, exactement comme
une révocation ferme celle d'un compte ordinaire.

### 7. Diagnostic d'une erreur 500

Une 500 sur mutualisé est muette par construction : `display_errors` est coupé,
et sans SSH on ne voit rien. Deux outils, dans cet ordre.

**`storage/logs/php.log`** — la journalisation est armée dès la première ligne
de `public/index.php`, *avant* le chargement de l'autoloader et de `config.php`.
Toute panne d'amorçage y laisse donc sa trace complète, y compris les erreurs
fatales, qui ne passent pas par le gestionnaire d'exceptions. Récupérez ce
fichier par FTP : dans la grande majorité des cas, il contient la réponse.

Si ce fichier est vide ou absent, c'est déjà une information : soit
`storage/logs/` n'est pas inscriptible, soit PHP n'a jamais démarré et l'erreur
vient d'Apache.

**`public/diagnostic.php`** — vérifie les causes qui ne laissent pas de trace :
version de PHP, extensions, arborescence, droits d'écriture, `config.php`
illisible, base injoignable, schéma non migré, racine web mal pointée.
Remplacez la constante `CLE` en tête de fichier par une chaîne aléatoire de 24
caractères, appelez `https://votre-domaine/diagnostic.php?cle=...`, **puis
supprimez le fichier**. Fermé par défaut : sans clé remplacée, il renvoie 404.

Il est volontairement autonome — aucune classe de l'application, aucune lecture
obligatoire de `config.php`. Il répond même quand l'application ne démarre pas ;
c'est toute son utilité.

**Si le diagnostic ne signale rien**, l'erreur est au niveau d'Apache, avant
PHP. Renommez `public/.htaccess` en `.htaccess.off` et rappelez le site : si la
500 disparaît, une directive est refusée par la configuration OVH. Réintroduisez
les blocs un par un ; `Options -Indexes` est le suspect habituel, car il exige
`AllowOverride Options`.

Les causes réelles, par fréquence décroissante :

| Symptôme | Cause |
|---|---|
| 500 sur toutes les URL, `php.log` absent | `.htaccess` refusé par Apache, ou `storage/logs/` non inscriptible |
| 500 sur toutes les URL, `php.log` renseigné | lisez-le : la trace nomme le fichier et la ligne |
| `Fichier de configuration absent` | `config.php` non transféré — il est dans `.gitignore`, un déploiement par git ne l'emporte pas |
| `SQLSTATE[HY000] [2002]` | hôte MySQL erroné : sur OVH c'est `basename.mysql.db`, pas `localhost` |
| `Base table or view not found` | migrations non appliquées |
| 404 sur toutes les URL au lieu d'une 500 | racine web pointée sur le dossier parent au lieu de `app/public` |
| Page blanche après connexion réussie | `session.secure = true` en http : le cookie n'est jamais émis |

### 8. Maintenance

Déclarez `app/tasks/cron.php` en tâche quotidienne : purge des jetons expirés et
des tentatives de connexion. Aucune granularité fine n'est requise — ne
dépendez jamais d'une exécution à la minute sur mutualisé.

## Ce que fait cette itération

| Parcours | Détail |
|---|---|
| Connexion | Superadmin depuis `config.php`, autres comptes depuis la base ; 5 échecs par email ou IP sur 15 minutes, message d'erreur indifférencié |
| Registre | `/utilisateurs` — tous les rôles visibles, actions limitées au périmètre de l'acteur |
| Création | Le créateur ne saisit jamais de mot de passe ; il transmet un lien, sauf pour prestataires et propriétaires qui n'en ont pas |
| Modification | Nom, email, téléphone, avec les changements consignés au journal. Le rôle, jamais |
| Profil | Admins et concierges éditent leurs coordonnées et leur mot de passe, l'ancien exigé |
| Révocation | `actif = 0`, jamais de suppression ; effet immédiat sur la session en cours |
| Réactivation | Mot de passe conservé, ou nouveau lien si le compte n'a jamais été activé |
| Réinitialisation | Nouveau lien à usage unique ; personne ne lit le mot de passe d'un autre |

## Cinq décisions à connaître avant d'ajouter du code

**Trois bornes, pas une.** La table de routage dans `public/index.php` dit qui
atteint quel écran. `Role::gere()` dans `src/Domain/Role.php` borne par **rôle**
de la cible. `Role::limiteAuxRattaches()` borne par **instance** : un concierge
n'atteint que les fiches dont `concierge_id` vaut son identifiant. Un tableau de
routes ne peut exprimer ni l'une ni l'autre, puisque la cible n'est connue qu'à
l'exécution. Les deux fichiers se lisent ensemble, et aucun contrôleur ne
redéfinit ces règles.

Les trois bornes passent par la clause `WHERE`, jamais par un filtre appliqué
après lecture. C'est la différence entre « on ne te l'affiche pas » et « tu ne
peux pas l'obtenir ».

**Hors périmètre renvoie 404, pas 403.** Un concierge qui forge l'URL d'un
administrateur obtient « introuvable ». La ligne n'est jamais lue : le rôle est
dans la clause `WHERE`, donc l'existence du compte n'est pas révélée.

**Voir et pouvoir agir sont deux choses — pour un administrateur.**
`Role::voit()` est plus large que `Role::gere()` : un admin voit les autres
admins, affichés sans commande et marqués « hors de votre périmètre ». Deux
exceptions, chacune pour sa raison :

- **le superadmin n'est visible que de lui-même.** Il relève de l'infrastructure
  et non du registre — il vit dans `config.php` et ne peut être ni modifié ni
  révoqué depuis l'interface. L'afficher n'apprendrait rien d'actionnable, tout
  en exposant l'adresse du seul compte qu'on ne peut pas désactiver en cas de
  compromission.
- **pour un concierge, `voit()` et `gere()` coïncident exactement.** Son registre
  ne contient que ses fiches, toutes modifiables. Une ligne grisée y serait le
  signe d'une fuite de périmètre, pas une information — un test le vérifie.

**Aucune lecture non cadrée dans les dépôts.** Il n'existe pas de
`trouverParId()` nu. Chaque méthode porte sa contrainte dans son nom
(`trouverParReferenceEtRoles`, `trouverActifParId`) et le rôle figure dans la
clause `WHERE`. C'est ce qui empêchera les fuites entre clients quand le
many-to-many propriétaire/bien arrivera.

**L'état du compte est relu à chaque requête.** `Auth::utilisateur()` interroge
la base et filtre sur `actif = 1`. Une révocation prend donc effet au
rafraîchissement suivant, sans attendre l'expiration de la session. Coût : une
requête indexée par requête HTTP. Le superadmin suit la même règle, mais contre
`config.php` au lieu de la base.

**On n'édite jamais une migration déjà appliquée.** Le DDL de MySQL n'est pas
transactionnel : une migration qui échoue en son milieu laisse un état partiel.
D'où un seul sujet par fichier, et un nouveau fichier pour chaque changement.
C'est pourquoi le renommage `client` → `proprietaire` est dans `003_roles.sql` et
non dans `001`, en trois instructions : élargir l'énumération, convertir les
lignes, resserrer.

## Suite

Itération 2 : les tables métier. `biens` et `interventions` viendront s'adosser à
`utilisateurs` par `utilisateur_id`, dans des fichiers `005_*.sql` et suivants,
sans toucher à l'existant. Les prestataires et propriétaires existent déjà comme
comptes : il n'y a plus qu'à leur rattacher des données.
