TQ-Slator/migrations/Version20260821090000.php
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

106 lines
5.4 KiB
PHP

<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Référentiel de langues.
*
* Dans une migration et non dans les fixtures, parce que ce n'est pas une
* donnée d'exemple : « français (France) » a les mêmes catégories plurielles
* pour tout le monde, et l'entité Locale est explicitement exemptée de
* l'isolation multi-organisation à ce titre (voir TenantIsolationTest).
*
* Le manque se voyait mal : une installation neuve démarrait, se laissait
* ouvrir, et n'échouait qu'au moment de créer un projet — sans langue source à
* proposer, le formulaire n'avait aucune option. Les fixtures masquaient le
* problème en développement, où elles peuplaient la table au passage.
*
* Les catégories sont celles du CLDR. Elles pilotent le nombre de champs que
* l'éditeur présente pour un pluriel : deux en anglais, trois en français,
* six en arabe. Se tromper ici produit une traduction incomplète que rien
* d'autre ne rattrape.
*
* INSERT IGNORE : la migration doit pouvoir se rejouer sur une base où les
* fixtures ont déjà inséré ces lignes, ce qui est le cas de toutes les bases
* de développement existantes.
*/
final class Version20260821090000 extends AbstractMigration
{
/**
* @var list<array{0: string, 1: string, 2: string, 3: string, 4: list<string>}>
*/
private const LOCALES = [
['fr', 'French', 'Français', 'ltr', ['one', 'many', 'other']],
['fr-FR', 'French (France)', 'Français (France)', 'ltr', ['one', 'many', 'other']],
['fr-CA', 'French (Canada)', 'Français (Canada)', 'ltr', ['one', 'many', 'other']],
['en', 'English', 'English', 'ltr', ['one', 'other']],
['en-GB', 'English (UK)', 'English (UK)', 'ltr', ['one', 'other']],
['en-US', 'English (US)', 'English (US)', 'ltr', ['one', 'other']],
['es-ES', 'Spanish (Spain)', 'Español (España)', 'ltr', ['one', 'many', 'other']],
['de-DE', 'German (Germany)', 'Deutsch (Deutschland)', 'ltr', ['one', 'other']],
['it-IT', 'Italian (Italy)', 'Italiano (Italia)', 'ltr', ['one', 'many', 'other']],
['pt-BR', 'Portuguese (Brazil)', 'Português (Brasil)', 'ltr', ['one', 'many', 'other']],
['pt-PT', 'Portuguese (Portugal)', 'Português (Portugal)', 'ltr', ['one', 'many', 'other']],
['nl-NL', 'Dutch (Netherlands)', 'Nederlands', 'ltr', ['one', 'other']],
// L'arabe est présent expressément : six formes et sens d'écriture
// inversé, c'est la langue qui casse les éditeurs mal conçus.
['ar', 'Arabic', 'العربية', 'rtl', ['zero', 'one', 'two', 'few', 'many', 'other']],
['he', 'Hebrew', 'עברית', 'rtl', ['one', 'two', 'many', 'other']],
['pl-PL', 'Polish (Poland)', 'Polski', 'ltr', ['one', 'few', 'many', 'other']],
['ru-RU', 'Russian (Russia)', 'Русский', 'ltr', ['one', 'few', 'many', 'other']],
['ja-JP', 'Japanese (Japan)', '日本語', 'ltr', ['other']],
['zh-CN', 'Chinese (Simplified)', '简体中文', 'ltr', ['other']],
];
public function getDescription(): string
{
return 'Installe le référentiel de langues : sans lui, aucun projet ne peut être créé.';
}
public function up(Schema $schema): void
{
foreach (self::LOCALES as [$code, $name, $nativeName, $direction, $plurals]) {
$this->addSql(
'INSERT IGNORE INTO locale (code, name, native_name, direction, plural_categories) '
.'VALUES (:code, :name, :nativeName, :direction, :plurals)',
[
'code' => $code,
'name' => $name,
'nativeName' => $nativeName,
'direction' => $direction,
'plurals' => json_encode($plurals, \JSON_THROW_ON_ERROR),
],
);
}
// Chaînes de repli : une traduction manquante en fr-CA retombe sur fr
// avant de retomber sur la langue source du projet.
foreach ([['fr-FR', 'fr'], ['fr-CA', 'fr'], ['en-GB', 'en'], ['en-US', 'en'], ['pt-PT', 'pt-BR']] as [$child, $parent]) {
$this->addSql(
'UPDATE locale c JOIN locale p ON p.code = :parent SET c.fallback_locale_id = p.id '
.'WHERE c.code = :child AND c.fallback_locale_id IS NULL',
['child' => $child, 'parent' => $parent],
);
}
}
public function down(Schema $schema): void
{
// On ne supprime que les langues qu'AUCUN projet n'utilise. Effacer une
// langue référencée casserait les traductions existantes en cascade, ce
// qu'un retour en arrière de migration ne doit jamais faire.
$this->addSql('UPDATE locale SET fallback_locale_id = NULL');
$this->addSql(
'DELETE FROM locale WHERE code IN (:codes) '
.'AND id NOT IN (SELECT locale_id FROM project_locale) '
.'AND id NOT IN (SELECT source_locale_id FROM project)',
['codes' => array_column(self::LOCALES, 0)],
['codes' => \Doctrine\DBAL\ArrayParameterType::STRING],
);
}
}