Files
Lesstime/.claude/skills/ticket-executor/LEARNINGS.md
T
2026-06-19 21:19:27 +02:00

24 KiB

Ticket Executor - Learnings

Session 2026-03-17 (26 tickets)

T-001 — Secrets .env

  • Pattern: Replace secrets with change_me_in_env_local placeholder, move real values to .env.local
  • Gotcha: .env.local must contain ALL overridden secrets

T-002 — Security API Gitea

  • Pattern: Ajouter security: "is_granted('ROLE_USER')" sur les opérations ApiResource
  • Learning: Vérifier d'abord les ressources déjà sécurisées pour ne pas dupliquer

T-003 — SVG Upload

  • Pattern: Double protection - bloquer à l'upload (retirer du MIME allowlist) + defense-in-depth (Content-Disposition: attachment au download)
  • Learning: Toujours vérifier upload ET download controllers

T-004 — MCP create-task / Repos numérotation

  • Gotcha critique: PostgreSQL n'autorise PAS FOR UPDATE avec des fonctions d'agrégation (MAX)
  • Fix: Utiliser pg_advisory_xact_lock() au lieu de FOR UPDATE pour les queries avec agrégation
  • Pattern: Offset les lock keys (+1000000) pour éviter collisions entre Task et ClientTicket

T-005 — Filter ROLE_CLIENT projects

  • Pattern: Créer une Doctrine Extension (QueryCollectionExtensionInterface + QueryItemExtensionInterface) pour filtrer par relation
  • Learning: Symfony autoconfigure enregistre l'extension automatiquement

T-006 — Block client doc upload

  • Pattern: Vérifier le rôle dans le Processor AVANT de résoudre l'IRI de la tâche
  • Learning: Le portail client envoie un clientTicket IRI (pas de task IRI), donc le check sur taskIri non-vide suffit

T-007 — MCP role checks

  • Pattern: Injecter Security dans chaque Tool, vérifier au début de __invoke()
  • Learning: 22 tools à modifier - bien séparer ROLE_ADMIN (users/clients) vs ROLE_USER (le reste)

T-009 — Password hashing

  • Pattern: Champ plainPassword non-persisté, writable uniquement, hashé dans le Processor
  • Learning: Modifier aussi le frontend (DTO + composant) quand on renomme un champ API

T-010 — Rate limiting

  • Gotcha: login_throttling nécessite symfony/rate-limiter installé, pas juste dans composer.json
  • Learning: Toujours vérifier que les packages sont installés, pas juste déclarés

T-012 — Harmoniser repos numérotation

  • Pattern: Aligner les contrats (retourner le max, pas le next) et mettre le +1 côté appelant
  • Learning: Vérifier TOUS les appelants d'une méthode renommée

T-015 — useAvatarService

  • Learning: Quand on migre vers useApi(), ajouter la détection FormData pour ne pas écraser le Content-Type multipart

T-020 — i18n

  • Pattern: Ajouter useI18n() dans le setup script avant de pouvoir utiliser t() dans le JS
  • Learning: Les templates peuvent utiliser $t() directement sans import

T-022 — Retirer twig-bundle

  • Pattern: Retirer de composer.json + bundles.php + supprimer config YAML + templates
  • Learning: API Platform ne requiert PAS twig, c'est juste suggéré pour Swagger UI

Session 2026-06-19 (LST-56 / 0.1 — Socle back modular monolith)

Contexte

  • Ticket exécuté via plan TDD dédié (docs/superpowers/plans/2026-06-19-lst-56-socle-back.md) délégué à un sous-agent (contexte isolé), pilotage MCP/chrono/vérif depuis la session principale.
  • 4 tâches, 14 nouveaux tests (110 total, 216 assertions, vert), 4 commits (un par tâche).

Patterns

  • Strangler 100 % additif : nouveau noyau src/Shared/ (Domain/Contract, Domain/Module, Domain/Sidebar, Domain/Trait, Application, Infrastructure/{ApiPlatform,Doctrine,Security,Database}) sans toucher au métier — make test reste vert sans migration.
  • Endpoints DTO purs : logique métier dans classes pures testées unitairement (ModuleRegistry, SidebarFilter), exposées par Providers API Platform minces (ModulesProvider/SidebarProvider) sur des Resources DTO.
  • resolve_target_entities : contrat Shared\Domain\Contract\UserInterface mappé sur App\Entity\User (sera re-pointé vers Module\Core\User en 1.1). Inert tant qu'aucune entité n'utilise le trait.

Gotchas

  • API Platform 4 découvre les Resources sous src/Shared/... sans config mapping.paths — le 404 anticipé dans le plan ne s'est pas produit, aucun ajout dans api_platform.yaml nécessaire.
  • Hook pre-commit php-cs-fixer normalise le style du code fourni dans le plan : \DateTimeImmutableDateTimeImmutable importé, FQN→use, static::createClient()self::. Pur style, tests inchangés. Ne pas lutter contre.
  • config/reference.php : fichier auto-généré qui apparaît modifié dans git status — ne jamais le committer.

Time tracking

  • Le sous-agent a stoppé lui-même le timer d'implémentation (id 1005, 35 min) — garder le time-tracking sur la session principale pour rester maître du chrono si un sous-agent a accès aux tools MCP lesstime.

Session 2026-06-19 (LST-62 / 0.2 — Socle front : shell + auto-détection layers Nuxt)

Contexte

  • Plan TDD dédié (docs/superpowers/plans/2026-06-19-lst-62-socle-front.md), 7 tasks. Exécution en 3 sous-agents (Task 1 back ; Tasks 2-4 fondations front ; Tasks 5-7 middlewares/layout/i18n), pilotage chrono/MCP/vérif sur la session principale.
  • 7 commits + 1 commit doc de correction du plan. Back : 115 tests verts (110 + 5 nouveaux cas gate rôle).

Patterns

  • Gate de rôle additif dans la sidebar : clé roles optionnelle sur section/item dans config/sidebar.php ; SidebarFilter::filter($sections, $activeModuleIds, $activeRoles = []) masque sans polluer disabledRoutes (réservé au filtrage par module). SidebarProvider injecte Symfony\Bundle\SecurityBundle\Security et passe array_values($user->getRoles()). ROLE_ADMIN seulement (pas le RBAC fin, qui viendra en 1.1/1.2).
  • Layout front aligné Starseed (vérifié dans le code Starseed) : srcDir: '.', dir.layouts/middleware → app/, code transverse auto-importé sous shared/{composables,stores,utils} via imports.dirs EXPLICITE, scan readdirSync('modules/')extends + dossiers modules/*/composables ajoutés dynamiquement à imports.dirs. useApi/auth/ui déplacés par git mv (historique préservé) ; timer.ts/mail.ts restent dans stores/ (métier non migré).
  • Singletons module-level : useSidebar/useModules portent leur état en ref au niveau module ; reset explicite au logout depuis auth.global.ts (l'approche Starseed via callback onAuthSessionCleared() est une alternative non retenue ici).

Gotchas

  • nuxt typecheck n'est PAS un gate vert sur ce stack : le baseline Lesstime est rouge (~230 lignes error TS) et la RÉFÉRENCE Starseed (même Nuxt 4.3.1, même layout) ship en prod avec 325 erreurs. Classes structurelles tolérées : Cannot find name 'ref'/'useApi'/'useRoute'/'navigateTo'/'defineStore'… dans shared/ (Nuxt 4 type shared/ sous un tsconfig.shared.json isolé sans les globals d'auto-import, alors que imports.dirs les expose au RUNTIME — vérifié dans .nuxt/imports.d.ts), erreurs nuxt.config.ts (node:fs/process/__dirname, pas de @types/node, compilé au runtime par Nuxt), useApi.ts 'Property url'. Le vrai gate = zéro Cannot find module '~/shared/…' (= vrai import cassé) + auto-imports présents dans .nuxt/imports.d.ts + smoke runtime. Un sous-agent consciencieux s'est arrêté à tort sur ces erreurs ("bloqueur irréductible") → toujours vérifier le gate contre la réf Starseed avant de conclure à un blocage.
  • Vérif backend live > typecheck front : le gate de rôle a été prouvé via curl réel (/api/login_check → cookie BEARER → GET /api/sidebar) : alice (ROLE_USER) n'a que la section générale, admin (ROLE_ADMIN) a Administration, non-auth = 401. Plus fiable que le typecheck sur ce stack.
  • i18n fr.json : une clé racine sidebar préexistait (avec un myTasks orphelin) → fusionner les sous-namespaces plutôt que dupliquer la clé racine (JSON invalide sinon).

Statut / time tracking

  • Ticket laissé en "En attente de validation" (4), pas "Terminé" : smoke visuel front (dev server + navigateur) et sign-off du délta cosmétique d'ordre de sidebar (décision 3 du plan) relèvent du PO. Implémentation + AC API validés.
  • Time-tracking 100 % sur la session principale cette fois (consigne des sous-agents : ne jamais toucher aux outils mcp__lesstime__*) — respecté.

Session 2026-06-19 (LST-63 / 1.1 — Module Core : identité User/Auth/JWT + Notifications + layer front)

Contexte

  • Plan TDD dédié (docs/superpowers/plans/2026-06-19-lst-63-module-core.md, 7 tasks / 6 phases A→F). Exécution : Phases A/B (1 sous-agent combiné), C (1 sous-agent), D (1 sous-agent), E + F faites en direct par la session principale (tâches courtes). Pilotage chrono/MCP/vérif + re-vérif login après chaque phase touchant l'auth sur la session principale.
  • 5 commits impl (6ca91cb A, f8fc4d6+d70925b B, 0b4874e C, f1a9b42 D, a98ea3d E, 117c2ff F) + plan 8865bf5. Tests : 110→120 verts. Timer impl 1012 = 43 min.

Patterns

  • Move d'entité « strangler » sans migration : git mv src/Entity/User.phpsrc/Module/Core/Domain/Entity/User.php (table + colonnes + backticks VERBATIM) ; mapping Doctrine Core ajouté (dir src/Module/Core/Domain/Entity, prefix App\Module\Core\Domain\Entity) à côté de App ; resolve_target_entities: UserInterface → Core\User. migrations:diff reste vide (hors dérive préexistante messenger_messages) → AUCUNE migration. Idem Notification en Phase D.
  • Alias temporaire pour découpler le move des relations : Phase B pose un class_alias(App\Entity\User::class → Core\User) (fichier _compat_user_alias.php en autoload.files, exclu de l'autowiring App\: via exclude services.yaml + notPath php-cs-fixer). Permet de relier d'abord les 8 relations d'entités au CONTRAT UserInterface::class (resolver propre) ; l'alias n'est qu'un pont de type-hint PHP. Phase C retire l'alias EN DERNIER, seulement quand grep App\Entity\User est vide.
  • Règle contrat-vs-concret pour migrer les consommateurs (Phase C, ~50 fichiers) : type-hint App\Shared\Domain\Contract\UserInterface si le fichier n'appelle que les méthodes de lecture du contrat / instanceof / type DQL ; FQCN concret App\Module\Core\Domain\Entity\User si besoin de getters HR, apiToken, avatarFileName, setters, new User(). Les deux éliminent App\Entity\User. Collision de nom avec Symfony\...\UserInterface → aliaser en SharedUserInterface.
  • Notifier (Phase D) : NotifierInterface (Shared) = API publique inter-modules ; impl Notifier (Core) persiste + flush. TaskNotificationListener appelle notify() UNIQUEMENT en postFlush (jamais onFlush — le flush interne y est dangereux). Comportement identique conservé.
  • Layer front d'un module (Phase F) : frontend/modules/core/nuxt.config.ts (export default defineNuxtConfig({})) + git mv des pages d'identité sous modules/core/pages/. Les imports ~/... (alias srcDir) survivent au déplacement ; seuls les imports relatifs/par chemin casseraient. Les URLs (/login, /profile) restent identiques (fusion auto des pages/ de layers).

Gotchas

  • admin.vue = shell admin MULTI-domaines (onglets clients/workflows/efforts/gitea/zimbra/mail/absences + 1 onglet AdminUserTab) : NE PAS le déplacer entier dans Core (il porterait les admins d'autres modules pas encore extraits). Conformément au plan, en cas de doute on déplace seulement login + profile, on documente. La décomposition de admin.vue viendra avec les modules respectifs.
  • Vérifier la résolution des routes d'un layer Nuxt en SPA : ssr:false → le dev server renvoie 200 pour N'IMPORTE QUEL chemin (shell SPA, routing client) — un curl /login = 200 ne prouve RIEN (testé : /route-bidon-xyz = 200 aussi). nuxt prepare ne génère pas le manifeste de routes. Preuve déterministe = npx nuxt build puis grep 'name:"login"\|name:"profile"' .output/server/chunks/build/client.precomputed.mjs (+ chunk CSS profile.*.css généré). Ne pas perturber un dev server déjà lancé (config extends/imports.dirs figée au démarrage avant création du layer) → lancer un dev frais sur un port libre pour smoke.
  • Aligner le contrat sur la réalité de l'entité, pas l'inverse : User::getUsername() est ?string (pas string) et la méthode réelle est getIsEmployee(): bool (pas isEmployee()). Le plan écrivait isEmployee() — le contrat existant était déjà correct, aucun changement. Toujours lire l'entité avant de figer une signature de contrat.
  • Tests fonctionnels qui persistent réellement (pas de rollback transactionnel ici) : un NotifierTest qui crée une notif échoue au 2e run (2 != 1) → rendre les données uniques (uniqid() sur le titre) pour l'idempotence.

Session 2026-06-19 (LST-57 / 1.2 — RBAC fin : portage Starseed)

Contexte

  • Plan TDD dédié (docs/superpowers/plans/2026-06-19-lst-57-rbac-fin.md, 7 phases A→G). Source de vérité = implémentation RBAC de Starseed (le brief attaché au ticket était inaccessible en local — fichier non synchronisé sur le stockage ; cartographié via un agent Explore sur /home/matthieu/dev_malio/Starseed). 1 sous-agent par phase, pilotage chrono/MCP/vérif/push sur la session principale.
  • 7 commits impl (A ffed224, B ac662e7, C 5060fb6, D 48c67a5, E 1a9eba9, F 544d4cf, G 511353c) + plan fdc7257. Tests 131→147 verts. Timer impl 1014.

Décision d'architecture majeure (actée, à valider PO)

  • RBAC additif, ROLE_ADMIN = bypass, PAS de colonne is_admin — divergence assumée vs Starseed (qui a supprimé la colonne JSON roles au profit de is_admin). Lesstime garde roles JSON + getRoles() (login/JWT/MCP/sidebar #62 reposent dessus) ; le PermissionVoter bypass si in_array('ROLE_ADMIN', $user->getRoles()). Réécrire l'auth aurait été une régression à haut risque pour zéro bénéfice AC. Migration future vers is_admin possible.

Patterns

  • RBAC = Role + Permission (M2M) + relations User : Role(code snake_case immuable, label, description, isSystem, ManyToMany permissions EAGER), Permission(code module.resource.action unique, label, module, orphan), User reçoit rbacRoles (table user_role) + directPermissions (table user_permission), getEffectivePermissions() = union triée dédupliquée. Migration 100% additive (5 CREATE TABLE, zéro DROP/ALTER sur user).
  • Permissions déclaratives par module : ModuleInterface::permissions(): list<array{code,label}>, agrégées par ModuleRegistry::permissions($activeClasses) (injecte module=id(), valide le préfixe). app:sync-permissions upsert (revive orphan / updateMetadata / create) + markOrphan des absentes. app:seed-rbac seede les rôles système (admin/user, isSystem) — sans matrice métier tant qu'aucune permission métier n'existe (les modules 2.x ajouteront leurs permissions + rôles).
  • Voter pur + bypass applicatif : PermissionVoter (regex /^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/ pour supports, donc abstient sur ROLE_*/IS_AUTHENTICATED_*). Le bypass admin de la sidebar est dans SidebarProvider (si ROLE_ADMIN → injecte le catalogue complet ModuleRegistry::permissions()), pas dans SidebarFilter qui reste un filtre pur (permissionSatisfied()). Le seed n'attachant aucune permission, sans ce bypass l'admin ne verrait rien.
  • Front : usePermissions() (can/canAny/canAll/isAdmin) dans modules/core/composables/ (auto-importé) ; type UserData enrichi de effectivePermissions ; onglet AdminRoleTab+RoleDrawer dans frontend/components/admin/ (le scan components Nuxt ne couvre que ~/components, PAS les layers modules/* → les composants vont dans components/, le composable/services dans modules/core/).

Gotchas

  • Symfony\Component\Serializer\Annotation\Groups N'EXISTE PLUS en Symfony 8 — seul Attribute\Groups existe. Un import Annotation\Groups rend tous les #[Groups] no-op silencieux (sérialisation cassée, POST en 400 car le constructeur n'est pas alimenté). Bug latent introduit en Phase A, révélé seulement par les tests fonctionnels de Phase D (TDD). Toujours utiliser Attribute\Groups. Vérifier la cohérence sur TOUTES les entités.
  • isSystem exposé sous la clé system : PropertyInfo strippe le préfixe is. Mettre #[Groups] + #[SerializedName('isSystem')] sur le getter pour conserver isSystem côté API.
  • options: ['comment' => ...] sur les colonnes des entités : sans le mapping options.comment, les COMMENT ON COLUMN de la migration créent une dérive migrations:diff perpétuelle (Doctrine veut les remettre à ''). Aligner le mapping entité sur le COMMENT de la migration.
  • make db-reset détruit lesstime_test (docker compose down -v supprime le volume) — les tests tournent sur la base suffixée _test. Après un db-reset, recréer la base de test : doctrine:database:create --env=test --if-not-exists + migrations:migrate -n --env=test + fixtures:load -n --env=test. Ne jamais lancer make db-reset depuis un sous-agent de phase.
  • Signature Voter::voteOnAttribute : la version Symfony installée impose voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token, ?Vote $vote = null): bool (4e param). Sans lui : « Declaration must be compatible » fatal.

MR / Git

  • MR empilées sur Gitea (tea pr create --base <branche-précédente>) reflètent la chaîne de dépendances (#56→develop, #62→#56, #63→#62, #57→#63) avec des diffs propres ; Gitea re-cible la base à chaque merge. tea pr n'a pas d'edit → pour sortir une MR du brouillon (retrait WIP:), PATCH API Gitea /repos/{o}/{r}/pulls/{n} avec le token de ~/.config/tea/config.yml.
  • WIP en cours : pousser la branche d'un ticket en cours + ouvrir la MR en brouillon (titre WIP:) sauvegarde le travail sans signaler « prêt à merger » ; re-pousser à chaque phase. Le push ne lock pas l'index → aucune contention avec un sous-agent qui committe en parallèle.

Meta-learnings

  • Parallélisation: Les tickets touchant des fichiers indépendants peuvent tourner en parallèle sans problème
  • Commits concurrents: NE PAS lancer deux sous-agents qui committent sur le même repo en parallèle (collision .git/index.lock) — séquencer.
  • Gate de vérif fourni par le plan: si un plan fixe un seuil (ex "typecheck 0 erreur"), le confronter à la réalité du projet/réf AVANT de bloquer dessus ; corriger le plan si le seuil est faux.
  • MCP status: Toujours mettre "En cours" AVANT de commencer, "Terminé" APRÈS validation
  • PostgreSQL gotchas: Tester les queries SQL avec agrégation + locking sur PostgreSQL, pas MySQL
  • Agents: Les agents simples (1-3 fichiers) terminent en ~30s, les complexes (22 fichiers) en ~8min

Session 2026-06-19 (LST-61 / 1.3 — Audit log : #[Auditable], audit_log, AuditListener, resource)

Contexte

  • Plan TDD dédié (docs/superpowers/plans/2026-06-19-lst-61-audit-log.md, Tasks A→F). Exécution : 1 sous-agent par task (A, B, C, D, E) en séquence, vérif + smoke par la session principale entre chaque ; Task F (validation finale + correctif front + learnings + push + statut) en direct.
  • Infra portée VERBATIM depuis Starseed (réf canonique /home/matthieu/dev_malio/Starseed) : AuditListener byte-identique (diff -q OK), + 6 fichiers API (DTO/paginator/providers/resources) copiés tels quels — namespaces App\Module\Core\... et App\Shared\Domain\Attribute\... DÉJÀ alignés entre les deux projets, zéro adaptation.
  • 6 commits impl (934cf08 A, d8553f0 B, 8c3699a C, 90b8ca1 D, e7af415 E, 9b26b43 fix front) + plan fda03bd. Tests : 147→157 verts. Branche feat/lst-61-audit-log empilée sur feat/lst-57-rbac-fin.

Patterns

  • Audit en 4 couches additives : (1) marquage déclaratif #[Auditable](TARGET_CLASS) / #[AuditIgnore](TARGET_PROPERTY) dans src/Shared/Domain/Attribute/ (Shared, pas Core → aucun module n'a de dépendance circulaire) ; (2) capture AuditListener Doctrine sur onFlush (lit UnitOfWork : insertions/updates/deletions + getScheduledCollectionUpdates/Deletions pour le M2M) puis postFlush (écrit, swap-and-clear anti-réentrance) ; (3) écriture AuditLogWriter sur connexion DBAL dédiée audit (hors transaction ORM → survit aux rollbacks) ; (4) lecture AuditLogProvider DBAL (pas d'entité ORM) + DbalPaginator implements PaginatorInterface (API Platform génère hydra:view seul).
  • Connexion DBAL dédiée + schema_filter : restructurer doctrine.yaml de connexion unique → connections: {default, audit} (même DSN), default_connection: default, schema_filter: '~^(?!audit_log$).+~' sur default (la table n'a PAS d'entité → exclue de migrations:diff/schema:validate). Le bloc orm reste INCHANGÉ (l'EM par défaut se lie à default_connection). En when@test, propager dbname_suffix aux DEUX connexions (sinon audit écrit en base dev pendant que l'ORM écrit en test).
  • Table append-only hors ORM : créée par migration manuelle (squelette via doctrine:migrations:generate puis contenu écrit à la main — JAMAIS migrations:diff, qui ne voit pas la table). id uuid natif PG, changes JSONB, performed_at TIMESTAMP(6) WITH TIME ZONE. UUID v7 (writer, tri monotone) / v4 (requestId par requête HTTP). entity_type au format module.Entity (regex App\Module\<module>\...\<Entity>core.User).
  • Marquage scope = entités migrées : #[Auditable] posé sur User/Role/Permission (Core) uniquement ; #[AuditIgnore] sur User.password ET User.apiToken (Lesstime n'a pas de plainPassword). Défense en profondeur : AuditLogWriter::SENSITIVE_KEYS strippe aussi password/plainPassword/apiToken/token/secret. Les entités métier legacy (src/Entity/*) seront marquées à leur migration en modules (2.x).

Gotchas

  • Tests fonctionnels Lesstime SANS rollback transactionnel (pas de DAMADoctrineTestBundle) : les entités persistées survivent d'un run à l'autre → violation d'unicité username. Convention projet : uniqid() OU nettoyage explicite en setUp() (DELETE FROM "user" WHERE username LIKE 'audit\_%'). Les données d'audit de test se seedent directement via doctrine.dbal.audit_connection (DELETE + inserts UUID v7) pour du déterministe.
  • migrations:diff génère un fichier jetable même quand on ne veut que vérifier : toujours supprimer le Version<ts>.php non suivi créé après un diff de contrôle (git ls-files --others migrations/). Une dérive préexistante messenger_messages (DROP) pollue le diff — sans rapport, ne pas committer.
  • /audit-log-entity-types = ressource item unique, pas une collection : Get API Platform avec uriTemplate fixe sans {id} → renvoie { entityTypes: string[] } (PAS d'enveloppe hydra member). Le service front ne doit PAS passer par extractHydraMembers ici (bug livré par le sous-agent E, corrigé en 9b26b43). /audit-logs en revanche est bien une collection paginée hydra.
  • Login en curl = /login_check (POST), pas /api/login ; le JWT json_login est capricieux en curl pur (405/cookie). La preuve d'auth faisant autorité reste le test fonctionnel (client loginUser()), pas un smoke curl.

Time-tracking / orchestration

  • Interdire explicitement aux sous-agents de toucher au MCP lesstime (timer + statut ticket) : un sous-agent a spontanément créé/stoppé une time entry (1016) alors que le chrono est piloté par la session principale. Ajouter la consigne « NE TOUCHE PAS au time-tracking » dans chaque prompt de sous-agent. Pas de conflit ici (il avait stoppé l'actif avant), mais découpage involontaire.