Le premier déploiement Coolify a échoué sur « Lazy loading proxies require
PHP 8.4 or higher ». La cause n'était pas dans le dépôt — Coolify avait
construit avec Nixpacks au lieu du compose — mais l'échec a mis au jour une
inexactitude réelle.
composer.json annonçait « php >=8.3 » alors que l'application exige 8.4 :
config.platform le pinçait déjà à 8.4.0, et les proxies de chargement paresseux
de Symfony ne fonctionnent pas en dessous. Toute chaîne de packaging qui lit la
contrainte pouvait donc choisir 8.3 en toute bonne foi, et échouer bien plus
loin sur un message sans rapport apparent. Contrainte remise à >=8.4.
compose.prod.yaml n'utilise plus `${VAR:?message}` : la forme arrête
correctement `docker compose`, mais le parseur de Coolify ne la comprend pas
partout. Le refus reste porté par docker/entrypoint.prod.sh, où il est testé et
où le message dit quoi faire plutôt que de citer un nom de variable.
Ajout au passage du garde-fou manquant : MARIADB_PASSWORD oubliée produisait
« mysql://tqslator:@database:… », un DSN valide au mot de passe vide, dont
l'échec remonte en erreur de droits d'accès sans jamais nommer la variable.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
539 lines
25 KiB
Markdown
539 lines
25 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
|
|
|
|
> **Le build pack doit être « Docker Compose ».** C'est le réglage qui décide de
|
|
> tout le reste, et Coolify ne le devine pas correctement : à la création d'une
|
|
> ressource il détecte « application PHP » et choisit **Nixpacks**, qui ignore le
|
|
> Dockerfile comme le compose, installe PHP 8.3 et lance
|
|
> `composer install --ignore-platform-reqs` — dépendances de développement
|
|
> comprises. Le build échoue alors sur
|
|
> `Lazy loading proxies require PHP 8.4 or higher`, une erreur qui ne dit rien du
|
|
> vrai problème : ce n'est pas le bon constructeur qui tourne.
|
|
|
|
1. **Nouvelle ressource → Docker Compose** (surtout pas *Nixpacks*), dépôt
|
|
`ssh://git@git.tranquilys.com:22222/Stephan/TQ-Slator.git`, branche `main`.
|
|
Sur une ressource existante : *Configuration → General → Build Pack*.
|
|
2. Fichier compose : `compose.prod.yaml`. Ne pas laisser `compose.yaml`, que
|
|
Coolify propose par défaut — Docker y fusionnerait `compose.override.yaml`.
|
|
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 domaine attribué par défaut est en `http://…sslip.io`. Il fonctionne, mais
|
|
les liens d'invitation partiront en clair : posez un vrai domaine en HTTPS avant
|
|
d'inviter qui que ce soit.
|
|
|
|
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
|
|
```
|