TQ-Slator/README.md
Stephan Morand ad104c82d7 Rendre le déploiement possible : image de production, amorçage, langues
L'image de production existait mais n'était jamais partie. Quatre manques la
rendaient inexploitable, tous silencieux — l'application démarrait dans les
quatre cas.

Le front n'était pas dans l'image
    public/build et public/index.html sont gitignorés, et le Dockerfile n'avait
    pas d'étage Node. L'API répondait, chaque URL de l'interface renvoyait 503 :
    une panne partielle, donc plus déroutante qu'une panne franche. Étage
    app_frontend ajouté, avec une vérification au build qui échoue si le front
    manque.

Aucune migration n'était appliquée
    Seul l'entrypoint de dev le faisait. docker/entrypoint.prod.sh applique les
    migrations sous garde RUN_MIGRATIONS et refuse de démarrer sur un APP_SECRET
    vide, un DATABASE_URL absent ou un BACK_OFFICE_URL sur localhost — cette
    dernière valeur ne casse rien de visible, elle casse la réception des
    invitations, ailleurs, plusieurs jours plus tard.

Aucun moyen de créer le premier compte
    Les comptes s'obtiennent par invitation, et inviter demande d'être connecté.
    Les fixtures sont une dépendance de dev. Il ne restait que l'INSERT à la
    main. app:create-super-admin crée l'organisation et le premier compte, refuse
    une adresse connue, exige douze caractères.

Le référentiel de langues était vide
    Créé par les fixtures, donc absent en production. Sans langue source à
    choisir, aucun projet ne pouvait être créé. Passé en migration : c'est une
    donnée de référence, pas un jeu d'exemple.

Au passage : l'expéditeur des courriels était en dur sur no-reply@tq-slator.local,
qu'un relais SMTP réel rejette. Devenu MAILER_FROM.

compose.prod.yaml est un fichier distinct, et c'est le point qui compte : Docker
fusionne compose.override.yaml dès que le principal s'appelle compose.yaml, si
bien que Mailpit et les ports publiés seraient partis en production sans que
personne ne l'ait demandé.

Parcours vérifié sur une pile neuve : connexion, création de projet, plateforme,
push de clés, publication, clé API, fichier livré.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 10:53:17 +02:00

525 lines
24 KiB
Markdown

# TQ-Slator
Système de gestion de traductions (TMS) headless — API-First, spécialisé i18n.
- **Management API** : Symfony 7.4 LTS + API Platform 4, OpenAPI généré depuis les attributs PHP.
- **Delivery API** : contrôleur dédié, bundles figés et immuables, ETag.
- **Base** : MariaDB 11.4.
- **Runtime** : FrankenPHP (worker mode en production).
L'architecture, les décisions et leurs justifications : [`docs/01-architecture-proposal.md`](docs/01-architecture-proposal.md).
---
## Démarrage
```bash
docker compose up --build -d
docker compose exec php bin/console doctrine:fixtures:load --no-interaction
```
| Service | URL |
|---|---|
| API | http://localhost:8080 |
| Documentation OpenAPI | http://localhost:8080/api/docs |
| Sonde de santé | http://localhost:8080/api/v1/health |
| Mailpit (invitations) | http://localhost:8025 |
Les migrations s'appliquent automatiquement au démarrage du conteneur `php`
(voir `docker/entrypoint.dev.sh`). Seul ce service migre ; le `worker` attend
que le schéma soit à jour avant de consommer.
## Comptes de démonstration
Mot de passe commun : `tqslator`.
| Compte | Rôle projet | Périmètre |
|---|---|---|
| `admin@tranquilys.test` | owner + super-admin | tout |
| `dev@tranquilys.test` | developer | clés, API, releases — **pas** les traductions |
| `maria@agence-lingua.test` | translator (externe) | es-ES, pt-BR **uniquement** |
| `jonas@agence-lingua.test` | translator (externe) | de-DE, nl-NL **uniquement** |
| `claire@tranquilys.test` | reviewer | es-ES, de-DE |
| `obs@tranquilys.test` | viewer | lecture seule |
Les deux comptes `agence-lingua` illustrent le RBAC à deux axes : María ne peut
pas écrire une ligne d'allemand, Jonas pas une ligne d'espagnol.
## Clés API de démonstration
```
tqs_test_development000000000000000000000 # translations:read, keys:read, keys:write, releases:read
tqs_test_staging0000000000000000000000000 # idem
tqs_live_production0000000000000000000000 # translations:read, releases:read — lecture seule
```
Déterministes en fixtures uniquement. La génération réelle passe par
`random_bytes` et le secret n'est affiché qu'une fois.
Vérifier une clé :
```bash
curl http://localhost:8080/delivery/v1/whoami \
-H 'Authorization: Bearer tqs_live_production0000000000000000000000'
```
## Jeu de données
Volontairement imparfait, pour que l'ergonomie soit jugeable dès le premier
écran : clés manquantes, sources modifiées après traduction (statut
`needs_review`), pluriels ICU, arabe en RTL avec six formes plurielles,
placeholders, et une clé rattachée à aucune plateforme (bac « Non assignées »).
## Back-office
Interface React servie par FrankenPHP en **même origine** que l'API — c'est ce
qui rend viable le cookie de session plutôt qu'un jeton en localStorage.
```bash
npm ci && npm run build # construit vers public/
npm run dev # Vite sur :5173, relaie /api vers :8080
```
Puis http://localhost:8080/login
Sept écrans, et un principe pour chacun :
- **Projets** — pas un tableau de bord. Répond à « où reste-t-il du travail, et
pour quelle langue ? », puis s'efface. Les langues sur lesquelles vous êtes
habilité passent en premier ; les autres sont grisées et marquées
« consultation ».
- **Éditeur** — trois zones et **deux façons de regarder le même contenu**
(voir plus bas). Grille virtualisée, saisie en ligne sans bouton
« Enregistrer », panneau de contexte permanent. Tous les filtres vivent dans
l'URL, y compris le choix de vue, donc un lien Slack amène la traductrice
exactement sur le bon travail.
- **Mode Focus** — une clé plein écran, `⌘↵` pour enregistrer et passer à la
suivante. Un traducteur ne vient pas explorer, il vient vider une file. Il
existe en deux variantes, une par vue : *une langue à la fois*, ou *toutes
celles qu'on a le droit d'écrire*.
- **Membres** *(administrateurs)* — invitation en trois champs (adresse, rôle,
langues), membres et invitations en attente dans la même liste. « Qui a
accès ? » doit se répondre d'un coup d'œil, accès accordés comme accès en
cours d'octroi.
- **Plateformes** *(administrateurs)* — les surfaces du projet et ses cibles de
déploiement, réunies parce qu'on s'y rend pour la même raison : rendre le
projet livrable. Une plateforme dit quelles clés entrent dans un fichier et
dans quelle syntaxe ; un environnement dit quelle version est servie.
- **Intégration** *(administrateurs)* — création et révocation des clés API.
Le secret n'apparaît **qu'une fois**. Les permissions d'écriture sont en
orange, les lectures en bleu : une clé de production n'a besoin que de
lecture, et la couleur le dit avant la documentation.
- **Administration** *(super-administrateurs, `/admin`)* — hors projet.
Deux onglets : tous les comptes de l'organisation avec leurs appartenances,
et tous les projets avec leur inventaire. C'est le seul endroit d'où l'on
crée un projet, désactive un compte ou nomme un super-administrateur.
Les entrées réservées aux administrateurs ne sont pas grisées, elles sont
**absentes** du menu. Un menu plein d'options inaccessibles apprend à
l'utilisateur que l'interface ne le concerne pas.
### Deux niveaux d'administration, pas un
| | Question posée | Qui répond | Où |
|---|---|---|---|
| **Projet** | Qui a accès à *ce* projet, et sur quelles langues ? | `owner` ou `admin` du projet | Onglets Membres et Intégration |
| **Organisation** | Qui existe dans l'outil, et où va-t-il ? | super-administrateur | `/admin` |
La seconde question traverse les projets, donc traverse aussi les
administrateurs de projet. Un administrateur du projet A n'a pas à savoir qui
travaille sur le projet B.
**Désactiver un compte coupe les sessions en cours**, pas seulement les
connexions suivantes : l'utilisateur est rechargé depuis la base à chaque
requête, et un compte inactif n'est plus trouvé. C'est ce qu'on attend d'un
bouton pressé au moment où un accès doit cesser.
Deux garde-fous : on ne modifie ni ne désactive son propre compte depuis cet
écran, et il doit rester au moins un super-administrateur actif.
### Archiver une plateforme, ne jamais la supprimer
Une plateforme **s'archive**. La supprimer effacerait son rattachement aux clés
via `translation_key_platform`, donc la seule trace expliquant pourquoi telle clé
existe.
Une fois archivée, elle sort des releases suivantes, refuse les `sync` (409, avec
un message qui dit que la décision est humaine et se défait depuis le
back-office), et disparaît des filtres de l'éditeur comme de `tqs init`. Elle
reste visible dans l'écran, avec sa date de retrait et son nombre de clés — le
chiffre qui rend la décision prenable. Les releases **déjà publiées** la
conservent : un instantané immuable ne se réécrit pas rétroactivement, sinon les
ETags déjà servis mentiraient.
La dernière plateforme active ne peut pas être archivée : le bouton est désactivé
avec l'explication, et le serveur refuse aussi.
Un environnement, lui, ne se supprime ni ne s'archive : les applications qui
l'interrogent cesseraient d'être servies, sans qu'aucun signal ne parte au moment
du geste. Le slug d'une plateforme comme d'un environnement est figé après
création — il vit dans les URL de livraison et dans les `tqs.config.json` des
dépôts clients.
Seuls les formats disposant réellement d'un sérialiseur (`icu`, `i18next`) sont
proposés à la création. Offrir `symfony` créerait une plateforme incapable de
recevoir un `sync` comme de produire un bundle : un choix sans issue, découvert
bien plus tard.
**Le traducteur ne voit jamais de syntaxe ICU.** Les variables s'affichent en
pastilles, les pluriels se saisissent dans un champ par catégorie CLDR de la
langue cible — trois en espagnol, six en arabe — et la recomposition en ICU
canonique se fait à l'enregistrement.
### Deux vues, deux questions
Une bascule en haut de l'éditeur, et le choix vit dans l'URL (`?view=`).
| | Question posée | Pour qui |
|---|---|---|
| **Par langue** *(défaut)* | Où en est mon travail en espagnol ? | La traductrice qui vide sa file |
| **Par clé** | Ce libellé est-il prêt partout ? | Avant une mise en production ; après avoir ajouté une clé |
En vue par clé, chaque ligne porte la source puis **une ligne par langue**,
repliée : code de langue, pastille de statut, valeur tronquée. On ouvre la seule
langue sur laquelle on veut agir. Sept champs de saisie ouverts d'emblée
produiraient un mur illisible — et le principe « deux langues à l'écran, jamais
douze » reste tenu, autrement : les langues sont **empilées et repliées**, pas
juxtaposées en colonnes.
Deux différences à connaître :
- **Le filtre de statut change de sens.** Par langue, « à traduire » désigne une
clé non traduite *dans cette langue*. Par clé, il désigne une clé non traduite
dans **au moins une** langue. Les compteurs de l'arbre des namespaces suivent
la vue, sinon ils contrediraient la liste qu'ils surplombent.
- **La recherche porte sur toutes les langues.** Chercher `Cancel` en vue par clé
trouve la clé même si le mot n'existe qu'en anglais.
Un traducteur voit **toutes** les langues en vue par clé — c'est même l'intérêt,
disposer des autres comme contexte — mais les langues hors de son habilitation
sont marquées « consultation » et n'offrent aucun champ de saisie. Le serveur
refuse également, langue par langue.
### Le mode Focus, en deux variantes
| | La file contient | Sur l'écran |
|---|---|---|
| **Par langue** | Les clés à traiter dans la langue affichée | Un champ |
| **Par clé** | Les clés à traiter dans **au moins une des langues qu'on peut écrire** | Un champ par langue restante |
Le gain de la seconde est précis, et c'est le seul qui la justifie : **la source
et son contexte se lisent une fois pour plusieurs langues**. María, habilitée en
espagnol et en portugais, rencontrait la même clé dans deux files séparées et
relisait deux fois « Annuler la séance » avant d'écrire deux phrases voisines.
`⌘↵` descend d'un champ, puis passe à la clé suivante — le geste reste celui du
mode Focus par langue, il traverse simplement plusieurs champs avant de changer
de clé. Les langues **déjà traduites** de la clé restent affichées en dessous,
en lecture : une traduction voisine tranche souvent mieux un registre que la
source elle-même.
La file ne contient que du faisable. Une clé qui ne manque qu'en allemand n'entre
pas dans la file d'une traductrice espagnole. Le sous-ensemble de langues est
calculé **côté serveur** à partir de l'identité courante (`?focus=1`), jamais
reçu du client : une file qu'on pourrait demander pour les langues d'un autre
serait une fuite d'information déguisée en confort.
### Inviter quelqu'un
```
Membres → Inviter → adresse, rôle, langues
```
L'e-mail part de façon asynchrone (Messenger). En développement il atterrit
dans **Mailpit : http://localhost:8025**. Le lien vaut 2 jours.
La page d'acceptation montre le projet, le rôle et les langues **avant** de
demander quoi que ce soit — on ne demande pas de créer un compte sans dire
pour quoi. Le mot de passe (12 caractères minimum) n'est défini que si le
compte n'existe pas encore : inviter une adresse déjà connue ne permet jamais
d'en réinitialiser le mot de passe. La connexion est automatique à
l'acceptation.
## Management API
Documentation OpenAPI générée depuis les attributs PHP : http://localhost:8080/api/docs
| | |
|---|---|
| `GET /api/v1/projects` | Projets visibles — filtrés au niveau SQL |
| `GET /api/v1/projects/{uuid}/grid` | **Grille de l'éditeur** : clé + source + cible en une requête |
| `GET /api/v1/projects/{uuid}/keys` | **Vue par clé** : clé + source + toutes les langues en une requête |
| `GET/PATCH /api/v1/keys/{uuid}/translations/{locale}` | Écriture validée par le moteur de format |
| `POST /api/v1/projects/{uuid}/platforms/{slug}/sync` | **Endpoint du CLI** : inventaire des clés, diff en retour |
| `GET /api/v1/locales` | Référentiel de langues (lecture seule) |
| `GET/POST /api/v1/projects/{uuid}/platforms` | Plateformes |
| `GET/POST/PATCH /api/v1/projects/{uuid}/platforms-admin` | Plateformes : création, réglages, archivage |
| `GET/POST/PATCH /api/v1/projects/{uuid}/environments` | Environnements : création et renommage |
| `GET/PATCH /api/v1/admin/users` | Annuaire des comptes — super-admin uniquement |
| `GET/POST /api/v1/admin/projects` | Inventaire et création de projets — super-admin uniquement |
Deux identités authentifient la Management API : **session cookie** pour le
back-office, **clé API** pour le CLI. Les Voters savent raisonner sur les deux —
une clé ne vaut que pour son projet, et ne peut jamais administrer ni créer
d'autres clés.
`/api/v1/admin/` est hors de portée d'une clé API quelles que soient ses
permissions, et la règle est posée deux fois : dans `access_control` et par un
`#[IsGranted]` sur chaque contrôleur. Une clé vit dans un dépôt et une chaîne
d'intégration continue ; elle n'a rien à faire près des comptes.
Trois garanties tenues par les tests :
- **Un non-membre reçoit 404, pas 403.** L'absence et l'interdiction sont
indiscernables : le nom d'un produit avant son annonce est une information.
- **Le `sync` est non destructif par défaut** et son `prune` est scopé
plateforme : retirer une clé du code web ne peut pas archiver une clé iOS.
- **Une traduction qui perd ou invente une variable est refusée** (422), de même
qu'un pluriel arabe incomplet. Un dépassement de longueur passe, en
avertissement.
## CLI `tqs`
Le binaire que les développeurs d'applications clientes installent. C'est lui
qui décide si l'outil est adopté ou contourné.
```bash
# Construire le binaire (un fichier, ~3 Mo, aucune dépendance)
php -d phar.readonly=0 bin/build-tqs-phar.php
cp build/tqs.phar /usr/local/bin/tqs
# Dans le dépôt de l'application cliente
export TQS_API_KEY=tqs_test_…
tqs init # déduit projet, environnement, plateformes et langues de la clé
tqs push --dry-run # ce qu'un envoi changerait
tqs push # applique
tqs pull # récupère les traductions publiées
tqs status # avancement par langue
```
En intégration continue :
```bash
tqs push --prune # code de sortie 2 si des clés sont refusées
tqs pull --check # échoue si des fichiers ne sont pas commités
tqs status --fail-under=80 # échoue si une langue passe sous 80 %
```
**La clé API n'est jamais dans `tqs.config.json`.** Ce fichier est commité — il
décrit le lien entre un dépôt et un projet, ce qui est une information d'équipe.
Le secret vient de `TQS_API_KEY`, et le CLI refuse explicitement un champ
`apiKey` dans la configuration : une clé commitée reste dans l'historique Git.
Le CLI ne connaît **rien** aux formats de messages : il aplatit du JSON et parle
HTTP. Tout le reste — parsing ICU, regroupement des pluriels i18next, validation
— vit côté serveur. Dupliquer le moteur dans le CLI garantirait qu'il diverge, et
un CLI qui valide différemment du serveur est pire que pas de validation.
## Releases et Delivery API
Publier fige le contenu en fichiers **immuables**, un par (plateforme, langue).
Déployer déplace un pointeur. Revenir en arrière déplace le même pointeur dans
l'autre sens — il n'y a pas d'endpoint de rollback, parce qu'il n'y a rien de
particulier à faire.
```bash
P=<uuid-projet>
E=<uuid-environnement>
# Publier, et déployer dans la foulée sur la recette
curl -X POST localhost:8080/api/v1/projects/$P/releases/publish \
-H 'Content-Type: application/ld+json' \
-d '{"notes":"Corrections espagnoles","deployTo":["staging"]}'
# Comparer deux versions
curl localhost:8080/api/v1/releases/<uuid-v1>/diff/<uuid-v2>
# Déployer — ou revenir en arrière, c'est le même appel
curl -X PUT localhost:8080/api/v1/environments/$E/release \
-H 'Content-Type: application/ld+json' -d '{"version":42}'
# Ce que lit l'application en production
curl localhost:8080/delivery/v1/tranquilys/production/web/es-ES.json \
-H 'Authorization: Bearer tqs_live_…'
```
Quatre propriétés que la publication continue ne peut pas offrir :
- **Un brouillon ne peut pas atteindre la production.** La Delivery API ne sait
lire que `release_bundle`, et un bundle ne contient que du publiable.
- **Rollback instantané.** Aucun fichier n'est régénéré.
- **Cache agressif sans invalidation.** Le contenu d'une release ne change
jamais ; seul le pointeur d'environnement bouge, et il n'est pas caché.
- **Diff entre versions**, calculé sur les fichiers réellement produits — donc
un changement de repli y apparaît, puisque l'utilisateur final le verra.
La publication est **tout ou rien** : si un seul bundle ne peut pas être
construit (`select` de genre vers i18next, conflit de structure imbriquée…),
aucun n'est enregistré et la réponse liste tout ce qu'il faut corriger.
## Moteur de format
Le stockage est **toujours** en ICU MessageFormat canonique ; les formats des
plateformes ne sont que des projections produites à la publication.
```
src/Translation/Format/
├── Ast/ nœuds : littéral, argument, plural, selectordinal, select, dièse
├── Parser/ IcuParser (récursif descendant), I18nextParser
├── Serializer/ IcuSerializer (canonique déterministe), I18nextSerializer
├── PlaceholderExtractor.php alimente translation_key.placeholders
├── MessageValidator.php erreurs bloquantes vs avertissements
└── MessageFormatRegistry.php aiguillage par MessageFormat
```
Trois invariants tenus par les tests :
- **`parse(serialize(ast)) == ast`** sur tout le corpus ICU.
- **La forme canonique est un point fixe** — condition de stabilité de
`source_checksum`, donc de l'absence de bascules `needs_review` fantômes.
- **Une construction non exprimable fait échouer la publication**, avec un
message qui dit quoi faire (`select` de genre, `offset:`, variable de pluriel
autre que `count`, sélecteur exact autre que `=0`).
Transformations lossy documentées et testées : un pluriel en milieu de phrase est
redistribué dans chaque clé i18next (rendu identique, structure différente), et
le type d'un argument ICU est perdu (le nom survit). Voir
`docs/01-architecture-proposal.md` §4.3.
## Déploiement
La pile de développement (`compose.yaml`) ne part **pas** en production : elle
cible l'image `app_dev`, monte les sources en volume, publie des ports et
branche Mailpit. `compose.override.yaml`, que Docker fusionne automatiquement,
en rajouterait. La production a donc son propre fichier :
```bash
docker compose -f compose.prod.yaml up -d --build
```
### Ce que l'image de production contient
Le back-office React est construit **dans l'image**, par un étage Node dédié.
C'est indispensable et non une commodité : `public/build/` et
`public/index.html` sont dans `.gitignore`. Sans cet étage, l'application
démarre, l'API répond, et chaque URL de l'interface renvoie 503 « Le back-office
n'est pas construit » — une panne partielle, donc plus déroutante qu'une panne
franche. Une vérification au build échoue si le front manque.
`npm run build` lance `tsc --noEmit` avant Vite : une erreur de typage casse le
déploiement, pas la production.
### Variables à renseigner
Quatre sont obligatoires. Absentes, le déploiement s'arrête au lieu de démarrer
un service à moitié configuré.
| Variable | Rôle |
|---|---|
| `APP_SECRET` | Signature des cookies de session. `openssl rand -hex 32` |
| `MARIADB_PASSWORD` | Mot de passe applicatif MariaDB |
| `MARIADB_ROOT_PASSWORD` | Mot de passe root MariaDB |
| `BACK_OFFICE_URL` | URL **publique**, `https://…`. Sert aux liens d'invitation |
| `MAILER_DSN` | Relais SMTP réel. Mailpit ne part pas en production |
Facultatives : `MAILER_FROM` (expéditeur, défaut `no-reply@tranquilys.com`),
`DEFAULT_ORGANIZATION_SLUG`, `RELEASE_RETENTION_UNREFERENCED`,
`SYMFONY_TRUSTED_PROXIES`, `CORS_ALLOW_ORIGIN`, `INVITATION_TTL`.
`BACK_OFFICE_URL` sur `localhost` est **refusé au démarrage**. Une valeur fausse
ne casse rien de visible : elle casse la réception des invitations, ailleurs,
plusieurs jours plus tard.
### Premier démarrage
Les comptes s'obtiennent par invitation, et inviter demande d'être connecté.
Une installation neuve a donc besoin d'un amorçage :
```bash
docker compose -f compose.prod.yaml exec php \
php bin/console app:create-super-admin vous@exemple.fr "Votre Nom"
```
Le mot de passe est demandé sans être affiché — il ne reste donc pas dans
l'historique du shell. Douze caractères minimum, comme à l'acceptation d'une
invitation : un premier compte plus faible que les suivants serait le maillon
par lequel on entre.
La commande refuse une adresse déjà connue : elle deviendrait sinon un moyen de
s'attribuer le compte d'un autre.
Le référentiel de langues est installé par une **migration**, pas par les
fixtures — c'est une donnée de référence, identique pour tout le monde, et sans
elle aucun projet ne peut être créé faute de langue source à choisir.
### Déployer via Coolify
1. **Nouvelle ressource → Docker Compose**, dépôt
`ssh://git@git.tranquilys.com:22222/Stephan/TQ-Slator.git`, branche `main`.
2. Fichier compose : `compose.prod.yaml`. Ne pas laisser `compose.yaml`, que
Coolify propose par défaut.
3. Renseigner les variables du tableau ci-dessus dans l'onglet *Environment*.
4. Attribuer le domaine au service **`php`**. `SERVICE_FQDN_PHP_80` est déjà
déclaré dans le fichier : Coolify y place le domaine et configure son proxy.
5. Déployer, puis lancer la commande d'amorçage ci-dessus depuis le terminal de
la ressource.
Le TLS est terminé par le proxy de Coolify ; Caddy a donc `auto_https off`, et
`SYMFONY_TRUSTED_PROXIES` est renseigné pour que Symfony lise
`X-Forwarded-Proto` — sans quoi les liens d'invitation partiraient en `http://`
depuis un site en `https://`.
Aucun port n'est publié sur l'hôte : le proxy atteint les conteneurs par le
réseau interne. Sur une VM sans Coolify, retirer `SERVICE_FQDN_PHP_80` et
ajouter `ports: ['80:80']` au service `php`.
### Ce que le déploiement fait tout seul
- **Migrations** — appliquées au démarrage par le service `php`, et par lui
seul. Le `worker` attend que le schéma soit à jour avant de consommer, sinon
il polluerait les journaux du premier déploiement avec des erreurs qui n'en
sont pas.
- **Cache applicatif** — figé au build, jamais régénéré au démarrage : deux
conteneurs issus de la même image doivent être identiques.
### Volumes à conserver
`db_data` et `app_storage`. Le second porte les captures d'écran de contexte et
les pièces jointes — le seul état applicatif hors base.
## Développement
```bash
docker compose exec php composer install
docker compose exec php vendor/bin/phpstan analyse --memory-limit=1G # niveau 8, doit rester à zéro
docker compose exec php bin/phpunit
docker compose exec php bin/console doctrine:migrations:migrate
```
### Points d'attention
- **`doctrine:migrations:diff` proposera de supprimer `idx_tkp_platform_key`.**
Ne pas accepter : Doctrine ne sait pas exprimer un index sur une table de
jointement ManyToMany, cet index est déclaré à la main dans la migration
initiale et sert la requête de grille.
- **Le dépôt est configuré en `core.ignorecase false`.** Les clés de traduction
sont sensibles à la casse, et macOS ne l'est pas par défaut.
- **Composer résout contre PHP 8.4**, la version du conteneur, pas celle de la
machine de dev. Voir `config.platform` dans `composer.json`.
## Structure
```
src/
├── Controller/ Auth, santé — les ressources métier passent par API Platform
├── DataFixtures/ Jeu de démonstration
├── Doctrine/ Filtre d'isolation multi-organisation
├── Entity/ 21 entités
├── Enum/ Statuts, rôles, formats, scopes
├── EventListener/ Établissement du contexte d'organisation
├── Message/ Messages asynchrones (Messenger)
├── Repository/ Requêtes — dont la requête de grille de l'éditeur
├── Security/ Authenticator par clé API, Voters
└── Tenant/ Contexte d'organisation
```