# TQ-Slator — Architecture (v0.2) > Statut : **décisions structurantes actées**. Reste à arbitrer : voir §7. --- ## 0. Le positionnement, avant la technique Un TMS n'est pas un CRUD sur une table `translations`. Trois vérités métier structurent toute l'architecture : 1. **Le problème n°1 d'un traducteur n'est pas de saisir du texte, c'est de savoir *quoi* traduire et *dans quel contexte*.** Une clé `common.submit` sans description, sans capture d'écran, sans longueur max, produit une mauvaise traduction. L'outil doit rendre le contexte obligatoire côté développeur. 2. **Le problème n°1 d'un développeur, c'est la synchronisation.** Le code évolue, les clés apparaissent et disparaissent. Si le push depuis le CI n'est pas trivial et non destructif, l'outil sera contourné en trois semaines. 3. **Le problème n°1 d'un manager, c'est la dérive silencieuse.** Une clé source modifiée après traduction = 11 traductions devenues fausses, sans aucun signal. C'est le bug le plus coûteux d'un TMS, et celui que les outils génériques ne traitent pas. Chaque décision ci-dessous découle de ces trois points. --- ## 1. Décisions actées (ADR condensés) | # | Décision | Conséquence principale | |---|---|---| | **1** | **Interne mono-org, schéma SaaS-ready** | `organization_id` sur toutes les racines + Doctrine filter global dès la 1re migration. Une seule org en base. Pas d'onboarding self-serve, pas de facturation, pas de quotas. Ouverture externe = chantier d'écrans, jamais de migration. | | **2** | **`project` + `platform` enfants** | Les clés appartiennent au projet ; une table pivot `translation_key_platform` marque leur appartenance (1..N). Le `sync` et les bundles deviennent scopés plateforme. | | **3** | **Symfony 7.4 LTS + API Platform 4** | OpenAPI généré depuis les attributs PHP, REST + GraphQL depuis la même définition, RBAC en Voters testables unitairement. | | **4** | **Releases versionnées** | Publication explicite → bundle figé + checksum. Aucun brouillon ne peut atteindre la production. Rollback instantané, cache immuable, diff entre versions. | | **5** | **Valeur de traduction partagée entre plateformes** | `UNIQUE (translation_key_id, locale_id)`. Pas de surcharge par plateforme. Un libellé qui doit différer devient une clé distincte. | | **6** | **Multi-format, canonique ICU MessageFormat** | `platform.message_format` pilote la sérialisation à la construction du bundle. La colonne `plural_forms` disparaît : le pluriel vit dans la valeur ICU. | | **7** | **RBAC = projet × langue** | `project_member` × `member_locale`. La plateforme n'est pas un axe de permission. | | **8** | **Consommation build-time (CLI + commit)** | Aucune dépendance runtime des apps clientes envers TQ-Slator. Delivery API à trafic faible, pas de CDN ni de SLA critique en v1. | --- ## 2. Architecture technique ### 2.1 Deux APIs, pas une Le besoin « API-First » recouvre deux contraintes opposées qu'on ne peut pas servir avec le même code. | | **Management API** | **Delivery API** | |---|---|---| | Consommateurs | Back-office, CLI `tqs` | Applications clientes (via CI) | | Forme | Ressources REST/GraphQL normalisées | Bundle JSON figé par (release, plateforme, langue) | | Latence cible | < 300 ms | < 20 ms | | Auth | Session cookie + CSRF | Clé API scopée environnement | | Données | Tout, brouillons compris | **Uniquement le contenu publié** | | Évolution | Versionnée, peut casser | Immuable | Les fusionner donne soit une API de management lente, soit une API de delivery qui fuit les brouillons en production. Firewalls, contrôleurs et stratégies de cache distincts dès le jour 1. ``` tqslator.internal/admin/* → SPA back-office (React) tqslator.internal/api/v1/* → Management API (API Platform, OpenAPI auto) tqslator.internal/delivery/v1/ → Delivery API (contrôleur dédié, ETag, immuable) ``` > Décision 8 : la Delivery API n'a pas besoin de CDN en v1, puisque les apps la consomment depuis leur CI et commitent les fichiers. Le contrat (`ETag`, `Cache-Control`, URL versionnée) est néanmoins respecté dès maintenant — passer un jour en OTA sera une décision d'exploitation, pas une réécriture. ### 2.2 Stack | Couche | Choix | Justification | |---|---|---| | Runtime | PHP 8.5 + **FrankenPHP** (worker mode) | Kernel en mémoire : 3–5× sur le chemin de delivery. Binaire unique, HTTP/3 | | Framework | Symfony 7.4 LTS | Support ~2030. API Platform 4 y est mature | | API | API Platform 4 | OpenAPI/Swagger auto, REST + GraphQL, filtres, pagination | | ORM | Doctrine ORM 3 + Migrations | Agnosticité base, migrations versionnées, Doctrine filter pour le tenant | | BDD | MariaDB 11.4 LTS | Contrainte projet. Pièges spécifiques : §3.6 | | Cache / Queue | Redis 7 | Cache des bundles, rate limiting, verrous d'édition concurrente | | Async | Symfony Messenger | Stats de complétion, webhooks, imports volumineux, construction des bundles | | Auth humain | Session cookie `SameSite=Lax` + CSRF | Same-origin → immunisé à l'exfiltration XSS, révocation immédiate. Pas de JWT en localStorage | | Auth machine | Clés API SHA-256, scopées, préfixées | `tqs_live_…` / `tqs_test_…`. Secret affiché une seule fois | | Front admin | React 19 + Vite + TanStack Query/Table/Virtual | Grille virtualisée + édition inline + sauvegarde optimiste | | Design system | Tailwind + Radix UI | Composants accessibles non stylés, clavier-first natif | | Recherche | MariaDB FULLTEXT → Meilisearch si > 500k traductions | Ne pas sur-architecturer au jour 1 | | Docs API | Scalar (rendu de l'OpenAPI généré) | Plus lisible et essayable que Swagger UI | | CLI | `tqs` — PHP packagé (Box/PHAR) | §2.4 | | Qualité | PHPStan 8, PHP-CS-Fixer, Rector, PHPUnit | Le palier utile sur du Doctrine : il couvre les nullables, source réelle de bugs ici. Le niveau 9 n'ajoute que la traque des `mixed`, majoritairement issus des colonnes JSON | | Local | Docker Compose (FrankenPHP + MariaDB + Redis + Mailpit) | `docker compose up` doit suffire | ### 2.3 Back-office : SPA React, pas Twig Le cœur du produit est une grille de plusieurs milliers de lignes, éditable inline, avec sauvegarde optimiste, navigation clavier intégrale et filtres instantanés. C'est un cas d'usage d'application, pas de site. - Twig + Turbo/Stimulus : chaque frappe implique un aller-retour ; la virtualisation de 30 000 lignes et l'undo optimiste s'y écrivent mal. - EasyAdmin / Filament : générateurs de CRUD. Ils produisent exactement « l'énième outil » qu'on veut éviter — la grille générique ne connaît ni les catégories CLDR, ni les placeholders ICU, ni le RTL. - React : **le back-office devient le premier consommateur de la Management API.** Si elle est pénible pour nous, elle le sera pour les intégrateurs. Dogfooding structurel. Coût assumé : une seconde stack, un build front, un contrat d'API à tenir. ### 2.4 Le CLI `tqs` Un TMS s'adopte ou se contourne au niveau du terminal du développeur. Avec la décision 8, le CLI **est** le chemin de production, pas un accessoire. ```bash tqs init # crée tqs.config.json, lie projet + plateforme tqs push --dry-run # montre le diff AVANT d'écrire (défaut : jamais destructif) tqs push --prune # retire explicitement les clés orphelines de CETTE plateforme tqs pull --env production --locale fr,es --out ./src/locales tqs status # 47 manquantes en ES, 12 sources modifiées tqs lint # placeholders incohérents, doublons, dépassements de longueur ``` `tqs pull` en CI + commit = **aucune dépendance runtime des apps clientes envers TQ-Slator**. Si le TMS tombe, les productions clientes ne tombent pas. C'est une propriété d'architecture, pas une commodité. ### 2.5 Publication par releases ``` édition (draft, review) ──▶ [Publier v44] ──▶ bundles figés (checksum SHA-256) │ production → v43 staging → v44 ``` - Un brouillon ne peut pas fuiter en production : garantie structurelle, pas procédurale. - Rollback = repointer l'environnement sur la release N-1. Instantané. - `Cache-Control: public, max-age=31536000, immutable` sur une URL contenant le checksum. - Le diff entre deux releases est une donnée de premier ordre. - **La construction des bundles exécute les sérialiseurs de format** (§4) : une transformation impossible bloque la publication avec un message actionnable, au lieu de produire une chaîne corrompue en production. Cardinalité : `plateformes × langues` bundles par release. 3 × 12 = 36. Avec une publication hebdomadaire, ~1 900 bundles/an/projet → politique de rétention : conserver les N dernières releases **plus** toutes celles référencées par un environnement. --- ## 3. Modèle de données ### 3.1 Vue d'ensemble ```mermaid erDiagram ORGANIZATION ||--o{ PROJECT : contient ORGANIZATION ||--o{ USER : membre PROJECT ||--o{ PLATFORM : declare PROJECT ||--o{ ENVIRONMENT : possede PROJECT ||--o{ PROJECT_LOCALE : active PROJECT ||--o{ NAMESPACE : structure PROJECT ||--o{ TRANSLATION_KEY : contient PROJECT ||--o{ PROJECT_MEMBER : autorise PROJECT ||--o{ GLOSSARY_TERM : definit PLATFORM ||--o{ TRANSLATION_KEY_PLATFORM : regroupe TRANSLATION_KEY ||--o{ TRANSLATION_KEY_PLATFORM : appartient LOCALE ||--o{ PROJECT_LOCALE : referencee LOCALE ||--o{ TRANSLATION : cible ENVIRONMENT ||--o{ API_KEY : porte ENVIRONMENT }o--|| RELEASE : pointe RELEASE ||--o{ RELEASE_BUNDLE : contient NAMESPACE ||--o{ TRANSLATION_KEY : regroupe TRANSLATION_KEY ||--o{ TRANSLATION : valeurs TRANSLATION_KEY ||--o{ KEY_SCREENSHOT : illustre TRANSLATION_KEY ||--o{ COMMENT : discute TRANSLATION ||--o{ TRANSLATION_VERSION : historise USER ||--o{ PROJECT_MEMBER : role PROJECT_MEMBER ||--o{ MEMBER_LOCALE : restreint USER ||--o{ AUDIT_LOG : trace ``` ### 3.2 Entités #### `organization` `id`, `uuid`, `name`, `slug`, `created_at`. **Une seule ligne en v1.** Toutes les entités racines portent `organization_id`, filtré automatiquement par un Doctrine filter activé au boot depuis le contexte de sécurité. Le coût aujourd'hui est d'une colonne et d'une classe ; le gain est de ne jamais avoir à auditer 200 requêtes le jour d'une ouverture externe. #### `project` | Colonne | Type | Note | |---|---|---| | `id` | BIGINT UNSIGNED AI | PK interne | | `uuid` | BINARY(16) | Exposé en API — jamais l'id auto-incrémenté | | `organization_id` | FK | | | `name`, `slug` | VARCHAR(120) | `slug` unique par organisation | | `source_locale_id` | FK locale | Langue de référence, commune à toutes les plateformes | | `key_naming_pattern` | VARCHAR(255) NULL | Regex optionnelle imposée aux clés (`^[a-z0-9_.]+$`) — qualité par construction | | `settings` | JSON | Longueur max par défaut, etc. | #### `platform` | Colonne | Type | Note | |---|---|---| | `project_id` | FK | `UNIQUE (project_id, slug)` | | `name`, `slug` | VARCHAR(80) | « App Web », `web` | | `kind` | VARCHAR(20) | `web` \| `ios` \| `android` \| `backend` \| `email` — pilote l'icône et les valeurs par défaut | | `message_format` | VARCHAR(20) | `icu` \| `i18next` \| `symfony` \| `laravel` \| `gettext` \| `apple` \| `android` — **pilote la sérialisation du bundle** (§4) | | `export_layout` | VARCHAR(20) | `nested` \| `flat` — structure du JSON produit | #### `translation_key_platform` (pivot) ```sql PRIMARY KEY (platform_id, translation_key_id) -- filtre grille = range scan INDEX (translation_key_id, platform_id) -- « à quelles plateformes appartient cette clé ? » ``` L'ordre de la PK est délibéré : filtrer 30 000 clés sur une plateforme devient un balayage de plage, pas un hash join. C'est ce qui neutralise le surcoût du modèle à pivot. Règles associées : - Une clé peut appartenir à **zéro** plateforme (créée dans l'UI, ou retirée partout) → bac « Non assignées » obligatoire dans l'éditeur, sinon la clé devient invisible et les compteurs mentent. - `tqs push --prune` depuis le CI web supprime la ligne pivot `(web, clé)`. La clé n'est archivée que lorsqu'elle n'appartient plus à **aucune** plateforme. Sans cette règle, le CI web archive les clés iOS. #### `environment` `(project_id, slug)` — `development` / `staging` / `production`. Porte les clés API et **pointe sur `current_release_id`**. Sans cette entité, impossible d'avoir staging sur v44 et production sur v43. #### `locale` Référentiel global, pas par projet. | Colonne | Note | |---|---| | `code` | BCP-47 : `fr-FR`, `pt-BR`, `ar` | | `name`, `native_name` | « French (France) » / « Français (France) » | | `direction` | `ltr` \| `rtl` — pilote la preview de l'éditeur | | `plural_categories` | JSON CLDR : `["one","other"]` (fr), `["zero","one","two","few","many","other"]` (ar) | | `fallback_locale_id` | `fr-CA` → `fr` → langue source | #### `translation_key` Le cœur. Chaque champ ici est un champ que le traducteur réclamera. | Colonne | Type | Justification métier | |---|---|---| | `project_id`, `namespace_id` | FK | | | `key_path` | VARCHAR(500) **`utf8mb4_bin`** | `header.navigation.login_button`. Collation binaire : §3.6 | | `key_hash` | BINARY(20) | SHA-1 de `key_path` → `UNIQUE (project_id, key_hash)` | | `description` | TEXT | **Le champ le plus rentable de la base.** Contexte pour le traducteur | | `placeholders` | JSON | Dérivé de l'AST ICU de la source : `[{"name":"nom","type":"string","example":"Marie"}]` | | `max_length` | SMALLINT NULL | Contrainte UI (bouton, colonne). Warning temps réel | | `is_archived` | BOOL | **Jamais de DELETE.** Une clé retirée du code peut revenir | | `last_seen_at` | DATETIME | Mis à jour à chaque `tqs push`. Détecte les orphelines | | `created_by`, `created_at` | | | > `is_plural` disparaît : avec un canonique ICU, le pluriel est une propriété de la valeur (nœud `plural` dans l'AST), pas de la clé. Une clé peut devenir plurielle sans migration — et sans solliciter un développeur. > **Index de la table pivot.** La clé primaire de `translation_key_platform` est `(translation_key_id, platform_id)`, ce qui sert parfaitement le `MEMBER OF` de la grille. La migration ajoute **en plus** `idx_tkp_platform_key (platform_id, translation_key_id)`, dans l'ordre inverse, pour que l'optimiseur puisse attaquer par la plateforme quand celle-ci ne porte que quelques centaines de clés. Doctrine ne sait pas exprimer un index sur une table de jointement ManyToMany : il est déclaré à la main et un `migrations:diff` proposera de le supprimer. Ne pas accepter. #### `translation` | Colonne | Type | Note | |---|---|---| | `translation_key_id`, `locale_id` | FK | `UNIQUE (translation_key_id, locale_id)` — décision 5 | | `value` | TEXT | **Toujours en ICU MessageFormat canonique.** NULL si non traduit | | `status` | VARCHAR(20) | `untranslated`, `draft`, `translated`, `needs_review`, `reviewed` | | `source_checksum` | BINARY(20) | **Le champ anti-dérive.** Hash de la valeur source **canonique** au moment de la traduction | | `is_machine_translated` | BOOL | Traçabilité, filtrable | | `updated_by_id`, `updated_at` | | | | `version` | INT | Verrouillage optimiste (deux traducteurs sur la même clé) | > **Les lignes existent pour toutes les langues activées, y compris non traduites.** Créer les `translation` par anticipation plutôt qu'à la première saisie est un arbitrage assumé : le filtre par statut devient uniforme, les statistiques se calculent par un simple `GROUP BY`, et le marquage `needs_review` tient en un seul `UPDATE`. Le coût est un volume de lignes vides — 30 000 clés × 12 langues = 360 000 lignes — que MariaDB absorbe sans difficulté. L'alternative paresseuse imposerait un `LEFT JOIN` avec traitement des `NULL` sur chaque requête de grille, et un total calculé par soustraction. > **`source_checksum` traite le problème métier n°3.** À chaque édition de la source, un `UPDATE … WHERE source_checksum != ` bascule toutes les cibles concernées en `needs_review`. Le manager voit « 34 traductions obsolètes ». Le hash porte sur la forme **canonique** : sinon web qui pousse `{{nom}}` et iOS qui pousse `{nom}` déclencheraient une bascule à chaque push croisé. #### `translation_version` Append-only : `translation_id`, `value`, `status`, `author_id`, `created_at`, `change_source` (`ui` | `api` | `cli` | `import` | `mt`). Audit, rollback unitaire, attribution du travail. Partitionnable par mois si le volume l'exige. #### `release` + `release_bundle` - `release` : `project_id`, `version`, `created_by`, `published_at`, `notes`, `key_count` - `release_bundle` : `release_id`, **`platform_id`**, `locale_id`, `checksum` (SHA-256), `payload` (LONGTEXT ou fichier), `size_bytes`, `format` (copie de `platform.message_format` à l'instant T) La Delivery API ne lit **que** `release_bundle` : une requête sur un index unique, aucun JOIN sur le pivot. Le coût du modèle à plateformes est entièrement absorbé à la publication. #### `completion_stat` `project_id`, `platform_id` (NULL = toutes), `locale_id`, `total_keys`, `translated`, `reviewed`, `needs_review`, `computed_at`. Les statistiques sont **bidimensionnelles** depuis la décision 2 : « ES à 94 % » ne veut plus rien dire si iOS est à 40 %. Rafraîchies en asynchrone via Messenger — surtout pas un `COUNT(*)` à chaque affichage de l'éditeur. #### `api_key` | Colonne | Note | |---|---| | `environment_id` | Une clé appartient à un environnement, jamais à un projet globalement | | `key_prefix` | VARCHAR(16), affiché dans l'UI : `tqs_live_a4f2…` | | `key_hash` | BINARY(32) SHA-256. **Le secret n'est montré qu'une fois** | | `scopes` | JSON : `["translations:read"]`, `["keys:write","translations:read"]` | | `last_used_at`, `expires_at`, `revoked_at` | Clés dormantes, rotation | #### `glossary_term` `project_id`, `term`, `locale_id`, `translation`, `is_forbidden`, `notes`. « *checkout* se traduit toujours par *commande*, jamais *caisse*. » Faible coût, forte valeur perçue par les traducteurs professionnels — différenciateur réel. ### 3.3 Autorisation (décision 7) ``` user ──< project_member (project_id, role) ──< member_locale (locale_id) ``` | Rôle | Portée | |---|---| | `owner` | Tout sur le projet, y compris suppression | | `admin` | Membres, langues, plateformes, réglages, publication | | `developer` | Clés (CRUD), plateformes, clés API, releases, lecture des traductions. **Pas d'écriture sur les traductions** | | `translator` | Écriture des traductions **sur ses langues assignées uniquement**. Lecture seule sur les clés | | `reviewer` | Comme translator + transition `translated` → `reviewed` | | `viewer` | Lecture seule | La plateforme n'est pas un axe de permission : un traducteur habilité sur (Tranquilys, ES) voit les clés ES de toutes les plateformes. Cohérent avec le fait que les clés sont majoritairement partagées. Implémentation : `ProjectVoter`, `TranslationVoter`, `ReleaseVoter`. Chaque décision d'accès est une méthode testée unitairement, pas une condition dispersée dans les contrôleurs. Rôles **fixes en v1** — un système de permissions dynamique multiplie la complexité par cinq pour un besoin non prouvé. ### 3.4 Volumétrie | Scénario | Clés | Langues | Lignes `translation` | |---|---|---|---| | Projet typique | 5 000 | 8 | 40 000 | | Gros projet | 30 000 | 15 | 450 000 | | 50 projets mixtes | — | — | ~6 M | Le point de vigilance n'est pas le volume mais **la requête de la grille** : « clés du namespace X, plateforme web, valeur source + valeur ES, statut = manquant, triées, paginées ». ```sql -- pivot : range scan grâce à l'ordre de la PK INDEX idx_grid (translation_key_id, locale_id, status) INDEX idx_key_lookup (project_id, namespace_id, key_path(191)) INDEX idx_stale (locale_id, status, updated_at) ``` ### 3.5 Multi-tenant : le Doctrine filter Un filter Doctrine activé au boot injecte `organization_id = :current` sur toutes les entités marquées. Deux garde-fous : - **Test d'architecture** : toute entité racine sans `organization_id` fait échouer la CI. - **Le filter est actif par défaut, désactivable explicitement** (commandes de maintenance, migrations). L'inverse — désactivé par défaut — est la recette d'une fuite inter-tenant. ### 3.6 Pièges MariaDB ⚠️ 1. **Collation et casse des clés.** En `utf8mb4_unicode_ci`, `Header.Login` et `header.login` sont **égales** → l'index UNIQUE rejette une clé légitime, ou pire, deux clés distinctes fusionnent. Les clés i18n sont sensibles à la casse. → `key_path` en `utf8mb4_bin`, reste de la base en `utf8mb4_uca1400_ai_ci` (MariaDB ≥ 10.10) pour une recherche naturelle sur les valeurs. 2. **Limite d'index InnoDB : 3072 octets** (row format DYNAMIC). Un `UNIQUE` sur `VARCHAR(500)` utf8mb4 = 2000 octets : ça passe, mais l'index est énorme et lent. → `UNIQUE (project_id, key_hash BINARY(20))` = 28 octets, + index préfixe `key_path(191)` pour les `LIKE 'header.%'`. 3. **`JSON` n'est pas un type natif en MariaDB** — alias de `LONGTEXT` + contrainte `CHECK (json_valid(…))`. Aucun index direct. → colonne générée persistante + index si un filtre est nécessaire. Acceptable ici : `placeholders` et `scopes` ne sont lus qu'à l'unité. 4. **`ENUM` et migrations.** Ajouter une valeur verrouille la table sur certaines versions. → `VARCHAR(20)` + validation applicative (choix retenu pour `status`, `message_format`, `kind`). Doctrine gère tout ça, à condition de le déclarer explicitement — ce n'est pas le comportement par défaut. --- ## 4. Moteur de format (décision 6) ### 4.1 Canonique ICU Une clé a une seule valeur partagée (décision 5), servie à des plateformes qui l'écrivent différemment (décision 6). Le stockage canonique est donc **obligatoire**, pas optionnel. ICU MessageFormat est retenu parce qu'il est le seul sur-ensemble des trois familles : il porte pluriels, ordinaux, genre et sélections *dans* la chaîne. i18next et sprintf en sont des projections appauvries. ``` value canonique (ICU) ──┬─▶ platform web : i18next {{var}} + clés _one/_other ├─▶ platform ios : ICU natif (identité) └─▶ platform backend : Symfony %name% ``` ### 4.2 Trois règles non négociables 1. **Le traducteur ne voit jamais de syntaxe ICU.** `{count, plural, one {# artículo} other {# artículos}}` est illisible pour un non-technicien. L'éditeur parse l'AST et affiche **un champ par catégorie CLDR de la langue cible**, puis recompose. Sans ça, on a construit un éditeur de code déguisé en TMS. 2. **`source_checksum` porte sur la forme canonique**, jamais sur la chaîne poussée (§3.2). 3. **Une transformation impossible bloque la publication.** Un `select` de genre ICU n'a pas d'équivalent i18next : la construction du bundle web échoue avec un message actionnable, plutôt que d'émettre une chaîne corrompue en production. ### 4.3 Deux pertes assumées, mesurées à l'implémentation Le moteur est écrit et testé (91 tests). Deux transformations vers i18next sont **lossy**, et il vaut mieux les nommer que les découvrir en intégration : 1. **Un pluriel au milieu d'une phrase est redistribué.** ICU écrit « Vous avez {count, plural, one {# message} other {# messages}} non lus » ; i18next fait porter la variation à la clé entière, donc le texte alentour est recopié dans chaque branche (`…_one` = « Vous avez {{count}} message non lus »). L'AST ne revient donc pas à sa forme d'origine. Le **rendu**, lui, est identique — le test confronte les deux formes au moteur ICU de PHP pour quatre valeurs de `count`. 2. **Le type d'un argument est perdu.** `{date, date, long}` devient `{{date}}` : i18next délègue le formatage à ses propres `formatters`, déclarés côté application. Le **nom** de la variable survit, ce qui préserve le contrat avec le code appelant. Une troisième subtilité a été tranchée par la langue cible : le suffixe `_zero` d'i18next peut venir d'un sélecteur exact ICU `=0` ou de la catégorie CLDR `zero`, qui existe réellement en arabe. Si `zero` figure dans les catégories de la langue lue, c'en est une ; sinon, ce ne peut être qu'un `=0`. ### 4.4 Composants et coût Chaque format demande un **parseur** (import) et un **sérialiseur** (export), avec un test aller-retour `parse(serialize(ast)) == ast` sur un corpus de fixtures. Il n'existe pas de parseur AST ICU mature en PHP — `intl`/`MessageFormatter` sait formater, pas produire un arbre. On écrit le nôtre sur un sous-ensemble borné (`argument`, `plural`, `selectordinal`, `select`, escaping) : ~400 lignes, entièrement testable, avec `@formatjs/icu-messageformat-parser` comme oracle pour générer les fixtures. **Validation dans l'éditeur** : une regex client-side donne le retour instantané sur la présence des placeholders (95 % des cas), le parseur PHP fait autorité au blur. Une seule implémentation normative — pas de dérive entre deux parseurs. ### 4.5 Séquençage | Phase | Import (parse) | Export (serialize) | |---|---|---| | v1 ✅ | ICU, i18next | ICU, i18next | | v1.1 | — | Symfony/Laravel, gettext `.po` | | v2 | Symfony, gettext | Apple `.strings`, Android `.xml`, XLIFF 2.1 | **sprintf `%s` reste export-only.** Des arguments positionnels non nommés produisent `{arg0}`, `{arg1}` en canonique — inexploitable pour un traducteur. Une plateforme backend qui doit *pousser* des clés utilise la syntaxe nommée (`%name%` Symfony, `:name` Laravel). --- ## 5. UX du back-office ### 5.1 Personas | | **Marie — traductrice** | **Sam — développeur** | **Léa — resp. localisation** | |---|---|---|---| | Fréquence | Sessions de 2 h, intensives | 5 min, deux fois par semaine | Coup d'œil quotidien | | Objectif | Vider une file de clés manquantes | Pousser des clés, récupérer les fichiers | Savoir si on peut livrer | | Outil concurrent | Un fichier Excel | `git grep` | Une relance Slack | | Ce qui la/le fait fuir | La souris. Le contexte manquant | Un formulaire web pour créer une clé | Un chiffre de complétion faux | **Les trois voient des applications différentes.** Marie n'a pas d'accès aux clés API. Sam n'a pas de champ de saisie de traduction. Léa a des graphiques que les deux autres n'ont pas. Une interface unique avec des boutons grisés est un échec d'UX. ### 5.2 Arborescence ``` / Sélecteur de projets — complétion par langue └── /p/{projet} ├── /editor ★ L'ÉCRAN. Défaut pour translator & reviewer ├── /keys Défaut pour developer — table technique, import/export ├── /platforms Plateformes, format de message, layout d'export ├── /locales Langues, fallbacks, avancement ├── /releases Historique, diff, rollback ├── /integration Clés API, snippets, doc, CLI [developer+] ├── /members Membres, rôles, langues assignées [admin+] ├── /glossary Terminologie └── /settings [admin+] ``` L'entrée dans un projet **ne mène pas à un dashboard**. On atterrit sur l'écran correspondant à son rôle, avec une action déjà chargée. ### 5.3 L'Éditeur — trois zones ``` ┌─────────────────────────────────────────────────────────────────────────────┐ │ Tranquilys FR → ES ▾ ⬡ web+ios ▾ [⌘K] ● 47 manquantes ⚠ 12 obsolètes│ ├──────────────┬────────────────────────────────────────┬─────────────────────┤ │ NAMESPACES │ CLÉS │ CONTEXTE │ │ │ │ │ │ ▸ common 12 │ ● cart.empty_state ⬡ web ios │ cart.empty_state │ │ ▾ cart 8 │ « Votre panier est vide » │ │ │ ▸ items 3 │ [Tu carrito está vacío___________] │ 📝 Affiché au │ │ ▸ checkout 27│ │ centre de la page │ │ ▸ errors 0 │ ⚠ cart.item_count ⬡ web ios and │ panier quand elle │ │ ─────────────│ « 1 article | {n} articles » │ est vide. │ │ ⊘ Non assig 2│ one [{n} artículo______________] │ │ │ ─────────────│ other[{n} artículos_____________] │ 📐 max. 40 car. │ │ FILTRES │ │ 🔤 {n} : nombre │ │ ○ Toutes 312 │ ✓ cart.checkout_cta ⬡ web │ │ │ ● Manquantes │ « Passer commande » │ 📸 [capture] │ │ ⚠ Obsolètes │ [Realizar pedido________________] │ ⏱ Historique │ │ ○ À relire │ │ 💬 Commentaires 2 │ └──────────────┴────────────────────────────────────────┴─────────────────────┘ ``` Décisions et leurs raisons : - **Deux langues à l'écran, jamais douze.** Personne ne traduit vers 12 langues simultanément et une grille à 12 colonnes est illisible. Le sélecteur `FR → ES` est l'objet de navigation principal. *(Le principe vaut pour la vue par langue ; la vue par clé, §5.3 bis, le tient autrement.)* - **La plateforme est un filtre, pas un espace.** Multi-select, « toutes » par défaut. Chaque ligne porte ses pastilles d'appartenance (`⬡ web ios`). Marie traduit une fois pour tout le monde ; Sam filtre sur `ios` avant un build. - **Le bac « Non assignées » est permanent** dans l'arbre des namespaces. Une clé sans plateforme n'apparaît dans aucun bundle — elle doit être visible, pas silencieuse. - **Les pluriels sont des champs, pas de l'ICU.** L'éditeur affiche une ligne par catégorie CLDR de la langue **cible** (l'arabe en a six, le français deux) et recompose l'ICU à l'enregistrement. - **Panneau de contexte permanent, pas une modale.** Le contexte est la matière première du traducteur ; une modale l'oblige à choisir entre le lire et saisir. - **Édition inline, pas de bouton « Enregistrer » global.** Autosave au blur, indicateur d'état par ligne (`⋯` → `✓`), rollback visible en cas d'échec réseau. - **Liste virtualisée.** 30 000 clés à 60 fps. Non négociable. - **Filtres dans l'URL** (`?status=missing&locale=es&platform=ios&ns=checkout`) : partageables dans Slack, restaurables, bookmarkables. Léa envoie un lien, Marie atterrit sur le bon travail. ### 5.3 bis La vue par clé — l'autre angle Une bascule en tête de l'éditeur, dont le choix vit dans l'URL (`?view=key`). ``` ┌─────────────────────────────────────────────────────────────────────────────┐ │ [Par langue│Par clé] fr-FR → 7 langues ⬡ toutes ▾ [rechercher…] │ ├──────────────┬────────────────────────────────────────┬─────────────────────┤ │ NAMESPACES │ booking.action.cancel ⬡ web ios 29% │ LANGUES │ │ (toutes │ fr-FR Annuler la séance │ ● en-GB traduite │ │ langues) │ ┌────────────────────────────────────┐│ ● es-ES traduite │ │ │ │ en-GB ● Cancel session ││ ○ de-DE à traduire│ │ booking 5 │ │ es-ES ● Cancelar sesión ▾ ││ ○ it-IT à traduire│ │ action 1 │ │ [Cancelar sesión__________] ││ … │ │ cancel 1 │ │ de-DE ○ À traduire ││ │ │ common 5 │ │ it-IT ○ À traduire consultation││ CONTEXTE │ │ … │ │ … ││ Action destructive…│ └──────────────┴────────────────────────────────────────┴─────────────────────┘ ``` **Deux publics, deux questions.** La vue par langue sert la traductrice qui vide sa file dans *une* langue. La vue par clé sert qui doit juger d'un libellé : le responsable de localisation avant une mise en production, le développeur qui vient d'ajouter une clé et veut savoir où elle en est. Aucune ne remplace l'autre, et c'est pourquoi les deux existent plutôt qu'un compromis qui servirait mal les deux. **Le principe des douze colonnes n'est pas abandonné, il est respecté autrement.** Les langues sont **empilées et repliées** — une ligne de vingt pixels chacune, code de langue, pastille de statut, valeur tronquée — et non juxtaposées en colonnes. On ouvre la seule langue sur laquelle on veut agir. Sept lignes se lisent ; sept colonnes ne se lisent pas. C'est aussi ce qui garde la virtualisation honnête : les hauteurs restent proches de l'estimation. **Le filtre de statut change de sens, et c'est dit dans l'interface.** 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 — la seule lecture qui ait un sens quand la ligne porte sept langues. Les compteurs de l'arbre des namespaces suivent la vue (`locale=*` côté API), sinon ils afficheraient le reste-à-faire espagnol au-dessus d'une liste qui montre tout : deux chiffres qui ne parlent pas de la même chose, côte à côte. Ce compte n'est pas la somme des autres : une clé incomplète en six langues compte une fois, d'où le `COUNT(DISTINCT)`. **La recherche porte sur toutes les langues.** Chercher `Cancel` trouve la clé même si le mot n'existe qu'en anglais. **Le cloisonnement par langue ne s'applique pas à la lecture, et c'est voulu.** Une traductrice espagnole voit l'allemand — c'est même l'intérêt de cette vue, disposer des autres langues comme contexte. L'écriture reste refusée langue par langue : les lignes hors habilitation sont marquées « consultation » et n'offrent aucun champ, et le serveur refuse également (403). **Coût technique.** Un DTO `KeyRow` distinct plutôt qu'un `GridRow` élargi : les deux vues n'ont ni la même cardinalité ni les mêmes filtres, et un DTO à géométrie variable aurait forcé chaque appelant à deviner ce qu'il tient. Pagination plus courte (50 contre 200) puisque chaque ligne porte autant d'entrées qu'il y a de langues. Et pagination **en deux temps** — identifiants d'abord, hydratation ensuite : une jointure de collection combinée à `setMaxResults` fait porter la limite sur les lignes du produit cartésien, pas sur les clés, et rendrait sept fois moins de résultats que demandé, silencieusement. ### 5.4 Le mode Focus — la fonctionnalité signature Marie n'explore pas, elle **vide une file**. Bouton principal : **« Traduire les 47 manquantes »**. ``` ┌──────────────────────────────────────────────────────────┐ │ 14 / 47 ▓▓▓░░░░░░ │ │ checkout.payment.card_declined ⬡ web ios │ │ │ │ FR « Votre carte a été refusée. Vérifiez vos │ │ informations ou essayez une autre carte. » │ │ │ │ ES ┌────────────────────────────────────────────┐ │ │ └────────────────────────────────────────────┘ │ │ 0 / 120 caractères │ │ │ │ 💡 Glossaire : « card » → « tarjeta » │ │ 💡 Similaire : checkout.payment.card_expired │ │ │ │ ⌘↵ valider et suivant ⌘⇧↵ marquer à revoir │ │ ⌥↵ suggestion auto Échap quitter │ └──────────────────────────────────────────────────────────┘ ``` Une clé, tout le contexte, zéro souris. Ce mode fait passer « traduire 47 clés » de 45 minutes à 12. **C'est là que se gagne la préférence utilisateur, pas dans le CRUD.** #### 5.4 bis Le mode Focus toutes langues Le pendant du précédent, ouvert depuis la vue par clé. Une clé, et **un champ par langue que l'utilisateur a le droit d'écrire et qui reste à faire**. ``` ┌──────────────────────────────────────────────────────────┐ │ 2 / 16 ▓▓░░░░░░░░ 1 traitée es-ES · pt-BR Quitter│ │ │ │ booking.cancel.confirm ⬡ web ios 14%│ │ Texte de la boîte de confirmation. │ │ │ │ Annuler définitivement cette séance ? │ │ │ │ ● es-ES Español ┌──────────────────────────────┐ │ │ └──────────────────────────────┘ │ │ ● pt-BR Português ┌──────────────────────────────┐ │ │ └──────────────────────────────┘ │ │ │ │ DÉJÀ TRADUITES │ │ en-GB Are you sure you want to delete this seance? │ │ │ │ ⌘↵ enregistrer et descendre Échap quitter │ └──────────────────────────────────────────────────────────┘ ``` **Le gain est précis, et c'est le seul qui justifie l'écran** : la source et son contexte se lisent UNE fois pour plusieurs langues. María, habilitée en espagnol et en portugais, rencontrait jusqu'ici la même clé dans deux files séparées et relisait deux fois « Annuler la séance » avant d'écrire deux phrases voisines. Trois conséquences de conception : 1. **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 : elle la traverserait sans rien pouvoir y faire, ce qui vide le mode de sa raison d'être. Le filtrage est serveur (`?focus=1`), et le sous-ensemble de langues est déduit de l'identité courante — **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. 2. **Les langues déjà faites restent visibles, en lecture.** Une traduction voisine déjà écrite est souvent une meilleure matière première que la source elle-même pour trancher un registre. 3. **`⌘↵` 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é. Un piège rencontré à l'implémentation, et qui vaut d'être noté : `[]` et `null` ne veulent pas dire la même chose côté langues écrivables. `null` = toutes, `[]` = aucune. Le `if (!empty($x))` réflexe aurait servi au développeur — qui n'écrit aucune traduction — la file de travail entière, sur laquelle il n'aurait rien pu faire. ### 5.5 Signalétique des statuts Un code couleur unique, partout, sans exception : | | Statut | Sens pour le traducteur | |---|---|---| | ○ gris | `untranslated` | À faire | | ◐ ambre | `draft` | Commencé, pas fini | | ⚠ rouge | `needs_review` | **La source a changé — cette traduction est peut-être fausse** | | ● bleu | `translated` | Fait, en attente de relecture | | ✓ vert | `reviewed` | Validé | Le rouge `needs_review` doit être **le plus visible de l'interface**. C'est le seul état qui représente un bug en production. ### 5.6 Garde-fous de qualité, en temps réel - **Placeholder manquant ou inventé** → erreur bloquante. `« Bonjour {nom} »` traduit en `« Hola »` casse l'application cliente. - **Dépassement de `max_length`** → avertissement non bloquant + compteur. - **Catégorie CLDR manquante** → l'éditeur exige toutes les formes de la langue cible. - **Balises HTML déséquilibrées** → erreur. - **Terme du glossaire non respecté** → suggestion douce, jamais bloquante. C'est ce qui fait qu'un traducteur non technique ne peut pas casser la production. Gain de confiance majeur côté développeurs. ### 5.7 Vue développeur - `/keys` : table dense, colonnes techniques (chemin, namespace, plateformes, dernière détection, langues traduites), sélection multiple, actions groupées, import/export. - `/releases` : **diff lisible entre deux releases.** « v43 → v44 : 12 clés ajoutées, 3 modifiées, 1 archivée. » - `/integration` : la clé API générée avec, juste en dessous, **le snippet prêt à coller dans la stack de la plateforme** (i18next, vue-i18n, react-intl, Symfony Translation). Le time-to-first-translation se compte en minutes. ### 5.8 Administration : faire entrer quelqu'un, et le cantonner Deux écrans, réservés aux administrateurs du projet — absents de la navigation pour les autres, et refusés lisiblement s'ils sont atteints par l'URL. **`/members` — invitations et habilitations.** L'invitation ne demande que trois choses : l'adresse, le rôle, et les langues. La liste des membres et la liste des invitations en attente sont **dans le même écran** : un administrateur qui se demande « qui a accès ? » doit voir d'un coup d'œil les accès accordés *et* ceux en cours d'octroi. Trois décisions de sécurité s'y jouent : - **L'écran est réservé aux administrateurs, pas ouvert à tout membre.** La réponse contient l'adresse e-mail de chaque personne du projet et de chaque invitation en cours ; un prestataire externe pourrait y énumérer l'équipe interne et observer les arrivées à venir. Le confort perdu (« qui relit mon travail ? ») est réel mais mineur. - **Le jeton d'invitation fait 256 bits et n'est stocké que haché** (SHA-256). Le lien en clair n'existe que dans l'e-mail. - **Accepter une invitation ne définit un mot de passe que sur un compte neuf.** Sinon, inviter une adresse existante permettrait de réinitialiser le mot de passe de son propriétaire — une prise de contrôle déguisée en invitation. La page d'acceptation montre le projet, le rôle et les langues **avant** de demander quoi que ce soit. On ne demande pas à quelqu'un de créer un compte sans lui dire pour quoi. Le dernier propriétaire ne peut être ni rétrogradé ni retiré : un projet sans propriétaire ne peut plus être administré du tout. Et retirer un membre retire l'appartenance, jamais le compte — il peut être membre d'autres projets, et son historique de traduction doit rester attribuable. **`/integration` — cycle de vie des clés.** Le secret n'est affiché **qu'une fois**, à la création, avec la phrase qui va avec : *« Copiez cette clé maintenant : elle ne sera plus jamais affichée. »* Ensuite, seul un préfixe tronqué subsiste, assez pour reconnaître la clé dans la liste, inutile pour s'en servir. Les permissions d'écriture sont **en orange** dans le sélecteur, les lectures en bleu. Une clé de production n'a besoin que de lecture ; la couleur le dit avant que la documentation ait à l'expliquer. Chaque clé porte son nom, son environnement, ses permissions et sa dernière utilisation — les quatre informations nécessaires pour répondre, dans six mois, à « celle-là, on peut la révoquer ? ». ### 5.9 Administration d'organisation Un second niveau, distinct du précédent et réservé au **super-administrateur** : `/admin`. La séparation n'est pas décorative. Les écrans Membres et Intégration répondent à « qui a accès à *ce* projet ? ». Celui-ci répond à « qui existe dans l'outil, et où va-t-il ? » — une question qui traverse les projets, donc qui traverse aussi les administrateurs de projet. L'administrateur du projet A n'a pas à savoir qui travaille sur le projet B. **Onglet Utilisateurs.** Tous les comptes de l'organisation, avec pour chacun ses appartenances projet, ses langues et sa dernière connexion. Deux actions : activer/désactiver, nommer/retirer super-administrateur. L'écran signale en tête les **comptes sans aucun projet** — ils se connectent et ne voient rien, ce qui est toujours soit une invitation oubliée, soit un départ mal soldé. Trois protections, toutes exprimées par un bouton désactivé plutôt que par un refus après clic : - On ne modifie ni ne désactive **son propre** compte depuis cet écran. - Il doit rester **au moins un super-administrateur actif**. - La désactivation **coupe les sessions en cours**. Ce point a coûté une correction : sans `UserProviderInterface` sur le repository, Symfony recharge l'utilisateur par clé primaire et ne repasse jamais par la condition `isActive`. Désactiver n'aurait fermé que la porte d'entrée, laissant travailler qui était déjà entré jusqu'à l'expiration de son cookie — exactement l'inverse de ce qu'on attend d'un bouton pressé quand un accès doit cesser. Un test verrouille le comportement. **Onglet Projets.** L'inventaire complet, y compris les projets où le super-administrateur n'a aucun rôle : `GET /api/v1/projects` ne renvoie que ce dont on est membre, et un projet **sans propriétaire** resterait sinon invisible à la seule personne capable d'y remédier. Deux anomalies structurelles sont donc criées dans la liste : « aucun propriétaire » (plus personne ne peut inviter ni publier) et « aucune plateforme » (le projet existe mais ne livre rien). La création d'un projet pose d'un coup les trois environnements, la langue source comme langue du projet, et le créateur comme propriétaire. Chacun de ces trois oublis ne se manifesterait qu'au premier déploiement, loin de sa cause. ### 5.10 Plateformes et environnements Un écran, deux notions, parce qu'on s'y rend pour une seule raison : rendre un projet livrable. Une **plateforme** dit quelles clés entrent dans un fichier et dans quelle syntaxe ; un **environnement** dit quelle version de ce fichier est servie. Il faut les deux, et un projet fraîchement créé n'a que le second — l'écran le dit en tête plutôt que de laisser le découvrir à la première publication. **L'archivage remplace la suppression.** Effacer une plateforme effacerait son rattachement aux clés dans `translation_key_platform`, donc la seule trace expliquant pourquoi telle clé existe. Une plateforme archivée : - sort des releases suivantes, mais reste dans celles **déjà publiées** — un instantané immuable ne se réécrit pas rétroactivement, sinon les ETags déjà servis mentiraient ; - refuse les `sync` avec un **409** dont le message dit que la décision est humaine et se défait depuis le back-office. Un 404 aurait envoyé le développeur chercher une faute de frappe dans sa configuration ; - disparaît des filtres de l'éditeur et de la réponse `whoami` que lit `tqs init` ; - reste visible dans l'écran, avec sa date de retrait et son **nombre de clés** — le chiffre qui rend la décision prenable : « 0 clé » se retire sans réfléchir, « 340 clés » demande de vérifier d'abord. L'invariant tient à une seule règle : tout ce qui **produit** passe par `getActivePlatforms()`, tout ce qui **affiche** peut passer par `getPlatforms()`. Rien dans le langage ne l'impose, et l'oubli serait silencieux — la publication réussirait, en livrant une plateforme retirée. Un test pose la frontière. La dernière plateforme active ne peut pas être archivée : le bouton est désactivé avec son explication, et le serveur refuse également. Un projet sans plateforme active ne peut rien publier. **Un environnement ne se supprime pas**, ni ne s'archive. Il porte le pointeur de release que la Delivery API sert, et les clés d'accès. L'effacer couperait la livraison des applications qui le consultent sans qu'aucun signal ne parte au moment du geste : la panne se manifesterait chez le client, plus tard, et personne ne ferait le lien. **Les slugs sont figés après création**, pour les deux entités. Ils vivent dans les URL de livraison, dans les `tqs.config.json` des dépôts clients et dans les scripts d'intégration continue. Les changer casserait tout cela sans qu'aucune erreur ne remonte au moment du changement. **Seuls les formats disposant d'un sérialiseur sont proposés** (`icu`, `i18next`). Offrir `symfony` ou `gettext` créerait une plateforme incapable de recevoir un `sync` comme de produire un bundle — un choix sans issue, découvert bien plus tard. Le jour où ces sérialiseurs existeront (§4.5), ils apparaîtront d'eux-mêmes dans la liste. `autoPublish` n'est **pas** exposé. La colonne existe depuis la phase 0 mais aucun code ne la lit : un interrupteur qui ne fait rien est pire qu'un interrupteur absent — on le bascule, on constate que rien ne change, et on cesse de faire confiance au reste de l'écran. ### 5.11 Hors périmètre v1 Traduction automatique intégrée (DeepL/LLM), mémoire de traduction sémantique, workflows d'approbation configurables, collaboration temps réel, facturation. Tous légitimes, aucun bloquant. Le modèle de données ci-dessus les accueille sans migration destructive. --- ## 6. Contrat d'API ### Delivery API ```http GET /delivery/v1/{project}/{env}/{platform}/{locale}.json GET /delivery/v1/{project}/{env}/{platform}/{locale}/{namespace}.json Authorization: Bearer tqs_live_xxx If-None-Match: "sha256-a4f2…" 200 { "cart": { "empty_state": "Tu carrito está vacío" } } 304 Not Modified ← le cas nominal ETag: "sha256-a4f2…" Cache-Control: public, max-age=300, stale-while-revalidate=86400 ``` ### Management API (OpenAPI auto-généré) ```http GET /api/v1/projects/{id}/keys?platform=ios&namespace=cart&status=missing&locale=es POST /api/v1/projects/{id}/keys PATCH /api/v1/translations/{id} POST /api/v1/projects/{id}/platforms/{platform}/sync ← endpoint du CLI, idempotent POST /api/v1/projects/{id}/releases GET /api/v1/releases/{a}/diff/{b} ``` `POST /platforms/{platform}/sync` est le endpoint critique : il reçoit l'inventaire complet des clés détectées dans le code d'**une plateforme**, les parse depuis le format de cette plateforme vers le canonique ICU, et renvoie un **diff** (ajoutées / inchangées / orphelines). Il n'archive rien sans `prune: true` explicite, et le `prune` ne retire que l'appartenance à cette plateforme. Idempotent, rejouable, non destructif par défaut. --- ## 7. Trajectoire | Phase | Contenu | Critère de sortie | |---|---|---| | **0 — Socle** ✅ | Docker, Symfony, schéma complet, migrations, Doctrine filter, auth, seed | `docker compose up` → API qui répond | | **1 — Moteur de format** ✅ | Parseur/sérialiseur ICU + i18next, extracteur de placeholders, validateur | 91 tests verts, PHPStan 8 propre | | **2 — Management API** ✅ | CRUD, extension de visibilité, grille, `sync`, écriture validée, OpenAPI | 102 tests verts, 9 chemins documentés | | **3 — Éditeur** ✅ | SPA React, grille virtualisée, saisie en ligne, pluriels CLDR, mode Focus, lecture seule par habilitation | Parcours vérifié en navigateur headless | | **4 — Releases & Delivery** ✅ | Publication tout-ou-rien, bundles par (plateforme, langue), ETag/304, diff, déploiement et rollback, rétention | 111 tests verts, boucle vérifiée jusqu'au 304 | | **5 — CLI `tqs`** ✅ | init / push / pull / status, packagé en PHAR autonome de 3 Mo | Parcours complet vérifié depuis un dépôt vierge, hors du projet | | **6 — Administration de projet** ✅ | Invitations par e-mail, gestion des membres, cycle de vie des clés API, écrans Membres et Intégration | Traductrice externe invitée, arrivée, cantonnée à sa langue — vérifié en navigateur | | **7 — Administration d'organisation** ✅ | Annuaire des comptes, rôle super-admin, désactivation révocatoire, inventaire et création de projets | Projet créé de bout en bout depuis l'interface ; 115 tests verts | | **8 — Plateformes et environnements** ✅ | Écran de gestion des plateformes (archivage, pas de suppression), gestion des environnements | Projet créé, plateforme déclarée, clés poussées, release publiée et fichier servi — sans jamais toucher au seed ; 120 tests verts | | **9 — Vue par clé** ✅ | Bascule par langue / par clé, DTO et endpoint dédiés, compteurs toutes langues, mode Focus toutes langues | Saisie faite dans une vue et retrouvée dans l'autre ; file du Focus restreinte aux langues écrivables ; 126 tests verts | | **10 — Qualité & terrain** | Glossaire, commentaires, captures d'écran, formats additionnels, import de l'existant | Retour terrain d'une traductrice réelle | La phase 1 passe avant l'API : le moteur de format conditionne le schéma des valeurs, et le corriger après coup signifierait remigrer toutes les traductions. La phase 5 est le jalon d'adoption — c'est là qu'on saura si l'outil sera utilisé ou contourné. La phase 6 est celle qui rend l'outil transmissible : jusque-là, seul celui qui a lancé le seed pouvait s'en servir. --- ## 8. Questions ouvertes 1. ~~**Authentification**~~ — **tranché** : comptes locaux + invitations par e-mail (phase 6). Le point d'entrée SSO reste ouvert : `User` ne présume rien du mode d'authentification, et un `UserBadge` suffirait à brancher un fournisseur OIDC sans toucher au reste. 2. ~~**Traducteurs externes**~~ — **tranché, partiellement livré** : `isExternal` est porté par l'invitation et par le compte, les invitations expirent (2 jours par défaut), et les actions d'administration sont journalisées. La 2FA obligatoire pour les externes reste à implémenter — les colonnes existent, la contrainte non. Restent ouvertes : 3. **Existant à migrer** — fichiers `.json` / `.po` / `.strings` déjà en production ? Volume, formats, et est-ce l'amorce de la base ou un import ponctuel ? 4. **Langues cibles réelles** et nombre de plateformes par projet — dimensionne les index et la cardinalité des bundles. 5. **Hébergement cible** — Docker sur VM, Kubernetes, PaaS ? Détermine la stratégie de stockage des `payload` de bundles (colonne LONGTEXT vs objet sur disque/S3). 6. **Rétention des releases** — combien de versions conserver au-delà de celles référencées par un environnement ? 7. **Captures d'écran de contexte** — stockage local, S3, ou URL externe ? Et qui les produit (dev au push, ou upload manuel) ?