# Plateforme multi-pension

Une application Symfony sert toutes les pensions. `backmapension.<domaine>` héberge l’administration globale ; `<pension>.<domaine>` héberge le CRM de cette pension. Chaque pension possède ses données, documents, photos, logo, comptes de personnel et tarifs. Un sous-domaine inconnu est refusé. Les sessions et les cookies sont limités à la pension concernée.

## Essai immédiat sur ce poste

```sh
composer install
php bin/platform-local
```

Ouvrir http://backmapension.localhost:8080/plateforme. Au premier lancement, le terminal demande l’e-mail, le nom et un mot de passe provisoire du super administrateur, sans afficher le mot de passe. À la connexion, choisir un nouveau mot de passe puis scanner le QR avec une application d’authentification. Conserver les dix codes de secours affichés une seule fois. Aucun compte ni mot de passe universel n’est fourni.

Créer une pension depuis l’administration globale. Par exemple, le sous-domaine `demo` donne http://demo.localhost:8080. La création prépare un premier responsable et son compte personnel ; il doit également changer son mot de passe provisoire. Le personnel se gère ensuite dans le CRM de chaque pension. Un autre port peut être passé : `php bin/platform-local 8081`.

Le mode local utilise un registre `var/data/platform.json`, des données séparées dans `var/tenants/` et une clé privée `var/.platform-key`. Il convient aux essais sur une seule instance. Les fichiers historiques `var/data/pension.json`, ses documents et ses photos restent à leur emplacement. Le démarrage historique du README reste disponible avec `PLATFORM_MODE=0`.

## Hébergement MySQL / MariaDB

Le CRM accepte MySQL 8 et MariaDB 10.11. Le stockage a été vérifié sur MariaDB 10.11 avec de vrais utilisateurs limités à leur propre base. La vérification sur le moteur MySQL 8 lui-même reste à effectuer ; le SQL utilisé est commun aux deux moteurs (InnoDB, JSON, transactions et verrouillage de ligne).

Sur un hébergement mutualisé, créer dans le panneau de l’hébergeur :

1. Une base vide pour le registre global et son utilisateur.
2. Une base vide et un utilisateur **différent** par pension. Chaque utilisateur doit uniquement avoir accès à la base concernée.

Le CRM n’a pas besoin des droits `CREATE DATABASE`, `CREATE USER` ou `GRANT`. Les utilisateurs doivent pouvoir créer les tables et lire, ajouter, modifier et supprimer leurs données ; prévoir aussi `ALTER` et `INDEX` pour les évolutions du schéma. Le serveur doit proposer PHP 8.2 ou plus avec `pdo_mysql`, ainsi que les extensions du README. Le nombre de pensions dépend donc du nombre de bases autorisées par votre offre.

Pour un hébergement sans configuration de variables d’environnement, copier `deploy/platform.mysql.example.php` vers **`var/platform-config.php`**, renseigner le domaine et les accès au registre, puis protéger ce fichier avec les droits `0600`. Il est exclu de Git et doit rester hors du dossier public. Générer séparément `APP_SECRET` et `PLATFORM_ENCRYPTION_KEY` :

```sh
php -r 'echo bin2hex(random_bytes(32)), PHP_EOL;'
```

Lancer deux fois cette commande et utiliser des clés différentes. Encoder l’utilisateur et le mot de passe de l’URL avec `rawurlencode`, par exemple lorsque le mot de passe contient `@`, `#`, `:` ou `/`. Les clés et accès privés ne doivent pas être copiés dans le README.

Les variables équivalentes sont : `PLATFORM_MODE=1`, `TENANT_BACKEND=mysql`, `PLATFORM_DATABASE_URL=mysql://utilisateur:mot-de-passe-encode@serveur:3306/base_registre`, `PLATFORM_DOMAIN=votre-domaine.fr`, `APP_ENV=prod` et les deux secrets. Les variables déjà définies prennent priorité. Une définition explicite de `PLATFORM_MODE` ignore le fichier privé : les commandes de test local restent ainsi séparées de l’hébergement.

Configurer le domaine **backmapension.votre-domaine.fr** et les sous-domaines des pensions vers le même dossier **public/**, avec HTTPS. Un DNS générique facilite l’ajout de pensions ; si l’hébergeur ne le propose pas, créer chaque sous-domaine dans son panneau. Le dossier `var/` doit être persistant et accessible en écriture à PHP, et ne doit pas être servi sur le web.

Créer ensuite le super administrateur depuis le terminal de l’hébergement :

```sh
php bin/console app:platform:create-admin
```

La configuration privée est chargée automatiquement si aucune variable `PLATFORM_MODE` n’est définie. Se connecter sur **https://backmapension.votre-domaine.fr/plateforme**, terminer le changement de mot de passe et la double authentification. Lors de **Créer une pension**, renseigner le serveur, le port, le nom de sa base vide, son utilisateur et son mot de passe. Le CRM crée ses tables et son premier responsable. Les identifiants MySQL sont chiffrés dans le registre. Une base contenant déjà des tables est refusée sans effacement ; une base partiellement initialisée après une erreur est conservée pour diagnostic.

Si MySQL exige TLS, ajouter `?ssl_ca=/chemin/absolu/ca.pem` à l’URL du registre et définir `MYSQL_SSL_CA` pour les bases des pensions. La vérification du certificat reste activée.

Les imports, sauvegardes et restaurations fonctionnent également avec MySQL. Avec le fichier privé, utiliser directement `php bin/console app:tenant:transfer ...` et `php bin/console app:platform:maintain ...`, **sans le préfixe `PLATFORM_MODE=1`** des exemples locaux plus bas. Sauvegarder le registre avec les outils MySQL de l’hébergeur, les pièces privées et la clé de chiffrement. Aucun accès à votre hébergement ni déplacement de vos données n’a été effectué automatiquement.

## Essai avec PostgreSQL et préparation du déploiement

Les fichiers Docker prévoient PostgreSQL 16, PHP 8.3 avec ses extensions et Apache. Installer Docker avec Compose et vérifier que votre utilisateur peut accéder au moteur Docker. Sur ce poste, la configuration Compose a été validée, mais la construction des images n’a pas été exécutée faute d’accès au moteur Docker. Le provisionnement et les migrations ont été testés avec un serveur PostgreSQL 16 réel, indépendamment de Docker.

```sh
php bin/platform-config
docker compose --env-file .env.platform -f deploy/compose.yaml up -d --build
docker compose --env-file .env.platform -f deploy/compose.yaml exec --user www-data app php bin/console app:platform:create-admin
```

`.env.platform` a déjà été préparé sur ce poste. La première commande refuse de remplacer un fichier existant afin de conserver les secrets. Ce fichier privé contient des valeurs aléatoires ; ne pas l’ajouter à Git. Les commandes suivantes ouvrent la plateforme sur http://backmapension.localhost:8080/plateforme. La première connexion impose le changement de mot de passe et le second facteur.

Le registre global possède sa propre base. À chaque création de pension, l’application crée une base et un rôle PostgreSQL dédiés, retire le droit de connexion public et applique les migrations. Le rôle de la pension ne peut pas ouvrir la base d’une autre pension. Les mots de passe de ces rôles et les secrets de double authentification sont chiffrés avec `PLATFORM_ENCRYPTION_KEY`.

Les données persistantes sont dans le volume PostgreSQL et `var/platform-runtime/`. Les pièces privées sont séparées par identifiant de pension. Le dossier historique est monté dans `/legacy` en lecture seule pour permettre son import.

## Importer la pension actuelle

Créer d’abord la pension cible dans l’administration globale, puis effectuer l’import **avant de saisir de nouvelles fiches dans cette pension**. L’import copie les données historiques, contrats, archives, documents, logo et photos ; il conserve leurs identifiants. Le fichier source et ses pièces restent intacts. Une pension déjà remplie est refusée.

Depuis le mode local JSON, remplacer `ma-pension` par le sous-domaine créé :

```sh
PLATFORM_MODE=1 php bin/console app:tenant:transfer import ma-pension "$PWD/var/data/pension.json"
```

Avec Docker/PostgreSQL :

```sh
docker compose --env-file .env.platform -f deploy/compose.yaml exec --user www-data app php bin/console app:tenant:transfer import ma-pension /legacy/pension.json
```

Les comptes personnels existants sont importés avec leurs mots de passe chiffrés par hachage. Si la source ne contient aucun administrateur actif, le responsable créé dans la pension cible est conservé pour permettre la connexion. L’import est journalisé et ne peut pas être répété sur la même pension remplie.

Les commandes métier qui agissent sur une pension demandent un choix explicite en mode plateforme, par exemple :

```sh
PLATFORM_MODE=1 php bin/console app:user:create-admin --tenant=ma-pension
PLATFORM_MODE=1 php bin/console app:user:reset-password --tenant=ma-pension
```

## Administration et assistance

Le super administrateur peut créer les pensions, modifier leur nom, leur formule, leur date d’expiration et leur état. Une pension suspendue ou expirée bloque la connexion ; un changement d’état révoque ses anciennes sessions. La date d’expiration inclut la journée indiquée. La gestion des abonnements est manuelle : aucun paiement, prélèvement ou facturation automatique n’est intégré.

L’assistance demande une action explicite et un motif depuis la fiche de pension. Une autorisation à usage unique, valable deux minutes, ouvre une session de consultation limitée à quinze minutes. Un bandeau indique l’assistance ; les modifications sont refusées côté serveur. Le nom du super administrateur, le motif et les accès sont journalisés. L’assistance n’est possible que si la pension est accessible et possède un administrateur actif ayant terminé sa première connexion.

Pour récupérer un accès global depuis un terminal du serveur :

```sh
PLATFORM_MODE=1 php bin/console app:platform:create-admin --reset
# Seulement si le second facteur et les codes de secours sont perdus :
PLATFORM_MODE=1 php bin/console app:platform:create-admin --reset --reset-mfa
```

Avec Docker, utiliser le même préfixe `docker compose ... exec --user www-data app` que pour la création initiale. Le mot de passe est demandé en saisie masquée ; la récupération force un nouveau changement à la connexion. La réinitialisation du second facteur force son nouvel enrôlement.

## Sauvegardes, restauration et mises à jour

Sauvegarde d’une pension, y compris ses comptes et fichiers privés :

```sh
PLATFORM_MODE=1 php bin/console app:tenant:transfer backup ma-pension /chemin/prive/ma-pension.zip
```

Pour restaurer, créer une **nouvelle pension vide**, puis utiliser `restore-new nouveau-sous-domaine /chemin/prive/ma-pension.zip`. L’archive contient un manifeste SHA-256 ; les fichiers et les chemins sont vérifiés. La restauration refuse d’écraser une pension remplie. Le transfert ne sert pas à modifier les abonnements ou les comptes du registre global.

Pour appliquer les migrations à toutes les pensions et exporter les sauvegardes ainsi qu’un instantané du registre global :

```sh
PLATFORM_MODE=1 php bin/console app:platform:maintain --backup-directory=/chemin/prive/sauvegardes
```

Dans Docker, choisir un dossier sous `/var/www/html/var/` pour retrouver les exports dans `var/platform-runtime/` sur l’hôte. Cette commande peut être planifiée par le système ; aucune tâche cron n’est installée automatiquement. Un échec pour une pension n’arrête pas les autres et produit un code de sortie d’échec.

Conserver aussi une sauvegarde PostgreSQL complète, le dossier des pièces et **les secrets de `.env.platform`**. Sans la clé de chiffrement d’origine, le registre ne peut plus déchiffrer les accès aux bases ni les secrets MFA. Les exports contiennent des données personnelles et des comptes : les conserver hors du dossier public et les transférer vers un stockage de sauvegarde privé. Une restauration complète du registre se fait par restauration de sa base PostgreSQL avec sa clé d’origine ; aucun bouton de restauration globale n’est ajouté.

Sauvegarder avant une mise à jour, reconstruire l’application puis lancer `app:platform:maintain` sans `--backup-directory` pour appliquer les migrations. Ne pas supprimer les volumes ou changer les clés lors d’une reconstruction.

## Mise en ligne

Configurer le domaine et le DNS générique `*.<domaine>` vers le serveur. Dans `.env.platform`, définir `PLATFORM_DOMAIN` sans protocole ni port et `APP_ENV=prod`. Configurer HTTPS devant le port local de l’application à partir de `deploy/nginx.example.conf`, avec un certificat couvrant le domaine administratif et les sous-domaines. Définir `TRUSTED_PROXIES` sur l’adresse exacte du proxy vue par PHP, puis recréer les conteneurs. Ne pas utiliser une confiance générale envers toutes les adresses.

La production refuse de démarrer sans secrets, sans registre SQL ou avec le stockage JSON. Les requêtes de production doivent utiliser HTTPS. Le port PostgreSQL n’est pas publié sur l’hôte et le port de l’application reste lié à `127.0.0.1`, derrière le proxy.

Cette version vise un serveur applicatif avec stockage persistant. Les sessions, les verrous, les limites de connexion et les fichiers sont locaux ; plusieurs serveurs nécessiteraient des services partagés pour ces éléments. Les données métier sont conservées par enregistrement JSON dans MySQL ou JSONB dans PostgreSQL pour préserver le CRM existant ; une lecture charge actuellement les données de la pension. Des volumes importants demanderont des requêtes et index métier spécifiques.

## Vérifications

`composer test` vérifie le CRM historique et l’isolation multi-pension, les sessions, la MFA, l’assistance, l’import et la restauration. Les tests utilisent des données temporaires. Une vérification PostgreSQL supplémentaire est disponible dans `tests/platform.php` avec `PLATFORM_TEST_PG_DSN` et `PLATFORM_TEST_PG_USER`, pour un serveur local de test sur le port 55439 avec droits de création de bases. Elle crée puis supprime uniquement ses propres bases et rôles temporaires.

Pour le stockage MySQL, un test réel facultatif est fourni :

```sh
MYSQL_TEST_DSN='mysql:host=127.0.0.1;port=55440' MYSQL_TEST_USER=root php tests/mysql.php
```

Le serveur doit être un serveur de test local avec les droits de créer des bases et utilisateurs temporaires. Le test vérifie des comptes aux droits limités, le registre global, les transactions, les imports et restaurations, puis supprime uniquement ses propres bases et utilisateurs. Ces droits élevés sont nécessaires au test, pas à l’application hébergée.
