Skip to content

Dette technique au 02/07/2026

Back industrialisé mais chantiers récents livrés vite, front en rattrapage. Startup 9/10 — Entreprise : back 6.5/10 (était 7/10 au 15/06), front 4/10 (était 3/10). Le socle back tient (810 tests verts), mais la vélocité de fin juin (multi-enterprise, jobs d’expiration/relance, INSEE) a réintroduit de la dette de correctness et une faille critique (2.17).

Audit du 02/07/2026 (multi-agent back + front, findings vérifiés un par un). Back : 810 tests verts, mais deux régressions de rigueur — phpstan repassé à 41 erreurs (était « L5 sans baseline » clean, cf. 2.9) et les chantiers récents livrés avec des trous : path traversal critique sur images/delete (2.17), contrôles métier manquants sur feedback/accept/create-mission (2.18), cycle expiration/relance incohérent (2.19 — ✅ corrigé le jour même), Stripe multi-enterprise avec TVA figée (2.20). Les transactions argent (2.12) restent le chantier structurel #1, rejointes par 2.17/2.18 en priorité immédiate. Front : 92 tests (+28), error handling unifié tenu, mais le multi-enterprise fraîchement livré casse les push owner (1.14) et laisse des incohérences d’état (1.15). Deps : CVE low symfony/yaml (composer update suffit), laravelcollective/html abandonné, Laravel 10 (12 dispo) — pas urgent.

Section mise à jour au 10/06/2026 (audit complet du code).

1.1 Absence totale de test unitaire Urgent

Section titled “1.1 Absence totale de test unitaire Urgent”

Par soucis de rapidité les features ont été privilégiées aux tests unitaires (pas de TDD ici). État au 11/06/2026 : front = 4 tests (contrat get-profile : parsing V2 + chaîne de traduction, premier vrai test du front — le widget_test.dart template est supprimé), back = ~800 tests. Le déséquilibre reste énorme. Priorité suivante : cubits critiques avec fakes injectés (mission lifecycle, auth, tokens) — débloqué par la résolution du 1.8, et chaque tranche V2 (1.2) apporte ses tests de parsing gratuitement.

Avancement 23/06/2026 : outillage posé (bloc_test + mocktail, sans codegen). Tests de cubits avec repos mockés : WorkerDashboardCubit (12 cas), EnterpriseDashboardCubit (13), TokenOffers/TokenHistory/TokenBalance (~12), EnterpriseCubit (23 — CRUD, hydratation, résolution entreprise active, cache FlutterSecureStorage mocké via MethodChannel) — pagination, garde-fous (ré-entrance, hasMore), chemins d’erreur. Front : 4 → 64 tests. Reste : auth + mission lifecycle (cubits non encore couverts, payment_history/token_purchase bloqués par DI en dur / SDK Stripe), et la CI (1.3) pour bloquer les régressions.

Avancement 02/07/2026 : 92 tests (+28 : enterprise_cubit, profile_cubit, session_cubit, worker_profile_cubit — commit d8d837e). Manquent toujours : auth et mission lifecycle.

Avancement 09/07/2026 : 104 tests (+12 : guard anti-concurrence + fallback deleteAccount de profile_cubit, parsing update_profile_request, etc.). Manquent toujours : auth et mission lifecycle (les 2 tranches critiques non couvertes) + la CI (1.3).

1.2 Écart models front vs back : ancien format à plat vs nouveau format segmenté Important

Section titled “1.2 Écart models front vs back : ancien format à plat vs nouveau format segmenté Important”

Le back a été refactoré : les réponses sont passées d’un ancien format à plat (tout au même niveau dans le JSON) à un nouveau format propre et segmenté (objets imbriqués par domaine : profile, stats, enterprise…). Côté front, les models datent majoritairement de l’ancien format. Des “ponts” de compatibilité ont été posés au cas par cas (ex : workerHistory, updateMission) mais le gros du raccord reste à faire : casts après coup dans les cubits ou pire dans l’UI, champs parsés en String par défaut. Sont suspectés :

  • User
  • Mission (Important : full String, ex rate_per_hour parsé en .toString() → donne "null" si absent)
  • WorkerProfile
  • Looking
  • Rank
  • Notification
  • TokenLedger
  • Feedback

À terme : aligner chaque model front sur le nouveau format segmenté du back et supprimer les ponts ancien→nouveau.

Avancement 11/06/2026 — stratégie strangler fig posée (principes dans CLAUDE.md §6) : lib/data/modelsV2/ miroir 1:1 des 11 Resources back + helpers de parsing typés. Tranche 1 (get-profile) faite : repo parse exclusivement le V2 (GetProfileResponse), traducteurs fromNewModel posés sur User, WorkerProfile, Rank, Assignment, EnterpriseProfile (tous marqués @Deprecated), test de contrat sur fixtures. Liste de migration restante par model : grep -rln "data/models/<model>.dart" lib.

Avancement 02/07/2026 (commit 973b73a) : tranches enterprise (enterprise_private_resource/enterprise_public_resource consommés par EnterpriseCubit, repo et les écrans owner/dashboard) et user/worker_profile/rank/assignment (user_private_resource + segments) faites. Restent les models legacy Mission, Notification, TokenLedger, Feedback, Looking.

Avancement 09/07/2026 (vérifié) : les models legacy Notification, TokenLedger et Looking sont supprimés (0 usage ; looking_resource en place). Ne restent plus que data/models/mission.dart (23 usages) et data/models/feedback.dart (4 usages) — leurs Resources V2 (mission_resource, feedback_resource) existent déjà, il reste à basculer les consommateurs et poser les traducteurs.

Rupture de contrat 15/07/2026 — rank imbriqué sur getUserPublicProfile (cf. back 2.14) : le rang du profil public cible n’est plus renvoyé en data.rank (top-level) mais en data.worker_profile.rank (RankResource complet, imbriqué et conditionnel — absent/null si la cible est un owner). Le front qui lit data['rank'] sur cet endpoint casse → basculer sur worker_profile.rank. Côté modèle segmenté c’est le sens voulu (rank = attribut du worker_profile) ; à répercuter quand le flux privé worker sera aligné de la même façon (le privé expose encore le rang en current_rank/next_rank au niveau contexte).

Nouveau champ 15/07/2026 — data.worker_profile.is_super (bool) : flag “super neo” ajouté sur getUserPublicProfile, true si le worker cible a été mis en favori par ≥ nb_super_neo_fav entreprises (seuil AppSetting). Présent uniquement sur cet endpoint (émis via whenCounted — omis partout où le compteur n’est pas eager-loadé). À exposer dans le model V2 worker_profile côté front.

Corrélé avec les tests unitaires : bloquer le push en prod en cas de test cassé et d’URL api dev en prod ou prod en dev. En l’état c’est inexistant et ça posera un problème en cas d’agrandissement de l’équipe.

1.4 Observabilité : AppLogger avale les stacktraces + print() en prod Important

Section titled “1.4 Observabilité : AppLogger avale les stacktraces + print() en prod Important”

Deux problèmes corrélés qui rendent le debug prod quasi impossible :

  • lib/core/utils/app_logger.dart : AppLogger.e() reçoit error et stackTrace en paramètres mais ne les log jamais, et tronque le message à 200 caractères. Fix de 5 lignes, gain énorme.
  • 32 print() bruts actifs en release, concentrés dans stop_overlay.dart, firebase_messaging_service.dart (~20, dont des payloads de notifs → fuite de données dans logcat) et login_screen.dart. À remplacer par AppLogger/debugPrint.

À terme : brancher le front sur GlitchTip comme le back.

1.5 Error handling : 3 patterns incompatibles ✅ Résolu 23/06/2026 (la part « patterns »)

Section titled “1.5 Error handling : 3 patterns incompatibles ✅ Résolu 23/06/2026 (la part « patterns »)”

Fix : repositories unifiés sur le Pattern A (T?, ApiErrorResponse?). Pattern B ((data?, String?)) et Pattern C (throw) éliminés sur tous les repos (chat, ticket, notification, feedback, enterprise, worker_dashboard, skill, mission, token, stripe, report, tension, event, profile/ranks) — y compris les 4 méthodes mission « type nu » qui throwaient encore (getMissionDetails, getMissionWorkerIds, getMissionOwner, updateMission). Tous les cubits/écrans consommateurs adaptés (error.message ?? fallback, le lock chat via error.message?.contains). Plus aucun rethrow dans les repos. 0 erreur analyzer, suite de tests verte.

Reste (au fil de l’eau, hors de cette passe) : les cubits silencieux (catch qui logge sans émettre d’état d’erreur, ex RegisterCubit.fetchDropdowns()) et l’usage des messages serveur au lieu de messages génériques dans les toasters.

1.6 Profil : race condition + flow bordélique ✅ Résolu 09/07/2026

Section titled “1.6 Profil : race condition + flow bordélique ✅ Résolu 09/07/2026”

Fix (en 3 temps) :

  • 02/07 (973b73a) — fetch initial orchestré une seule fois par SessionCubit (guard _inFlight) qui hydrate les cubits : plus de risque concurrent au bootstrap.
  • 09/07 (logique) — guard anti-concurrence posé sur fetchProfile (Loading OU Refreshing en vol → no-op, +2 tests) ; deleteAccount n’appelle plus StringUtils.loc hors contexte (fallback en dur, +1 test).
  • 09/07 (flow/UI)profile_screen_widget.dart éclaté 815 → 47 lignes en 6 sous-fichiers (settings_section, settings_menu_sections, settings_body, profile_banner, payment_history_button, app_version_text) ; les 3 sections Légal/Aide/Compte, dupliquées entre les 2 modes, factorisées dans SettingsMenuSections. Le mode showProfileInfo: true (grande photo + identité) n’avait plus aucun appelant → supprimé (~250 lignes, l’édition reste accessible via bandeau/dashboard → EditProfileScreen). Module profil assumé comme feature partagée et aligné sur la convention view/ : profile_screen.dart + edit_profile_screen.dartfeatures/profile/view/, écrans légaux → features/profile/view/legal/, update_device_token_request.dartdata/models/, payment_model.dart (0 usage) supprimé. features/auth/ ne contient plus que du vrai auth. features/profile est désormais de la présentation pure (zéro cubit/repo propre, lit les cubits globaux hydratés par SessionCubit).

Le point restant « refresh cross-cubit » (une entreprise modifiée côté back reste affichée jusqu’au relaunch) n’appartient pas à cette entrée → suivi en 1.15.

La migration est à ~95%, il reste un noyau dur concentré presque entièrement sur le flow auth :

  • lib/legacy/ : 3 fichiers de navigation restants (route_name.dart, navigation_helper.dart, route_stack_observer.dart), tenus en vie par 3 pushNamed dans login_screen.dart et register_form.dart
  • ~42 usages d’AppStyle.poppinsXXX restants (déprécié), dont ~20 dans register_form.dart02/07 : redescendu à 30 usages sur 18 fichiers
  • register_form.dart (659 lignes) cumule toutes les violations : _buildXxx au lieu de widgets extraits, AppStyle, pushNamed, import legacy. Refactorer ce seul fichier clôt l’essentiel du chantier.
  • ~18 build() dépassent la limite de 70 lignes (72-115 lignes) → à traiter au fil de l’eau

1.8 L’injection de dépendance ✅ Résolu 11/06/2026

Section titled “1.8 L’injection de dépendance ✅ Résolu 11/06/2026”

Fix : Les 4 derniers cubits réfractaires (ReadyToWorkCubit — y compris SocketClient, CreateMissionCubit, EditMissionCubit, RegisterCubit) convertis au pattern standard param ?? Default() ; ChatCubit passé en params optionnels et chat_screen ne construit plus les repos dans la view. Décision actée : DI manuelle par constructeur assumée, pas de GetIt/injectable (symétrie de pattern avec le back, graphe plat à 2 niveaux, zéro codegen) — injectable retiré du pubspec. Tous les cubits sont désormais testables avec des fakes.

1.9 NotificationCubit mélange de responsabilités Mineur

Section titled “1.9 NotificationCubit mélange de responsabilités Mineur”

NotificationCubit gère à la fois les notifications (fetch, socket, mark as read, delete) ET la soumission de feedback. C’est une violation du Single Responsibility. Le feedback devrait vivre dans un cubit dédié FeedbackCubit.

1.10 MissionRepository : worker + owner mélangés Moyen

Section titled “1.10 MissionRepository : worker + owner mélangés Moyen”

MissionRepository fait 340 lignes (02/07 : 425, la croissance vient surtout des docblocks) et contient les méthodes worker (accept, decline, getMissionWorkerIds) ET owner (create, cancel, giveFeedback, putInProgress). À terme, séparer en WorkerMissionRepository et OwnerMissionRepository pour la lisibilité et le respect du SRP.

  • update_profile_request.dart (model) importe dio/http_parser et construit le FormData → cette logique doit vivre dans le repository ✅ Résolu 09/07/2026 : model devenu classe pure (champs + toJson(), zéro import HTTP) et déplacé de features/auth/models/ vers data/models/ ; c’est ProfileRepository._toFormData qui construit le multipart (photo + content-type). http_parser promu dépendance directe du pubspec.
  • Dépendances inutilisées ✅ Résolu 11/06/2026 : flutter_screenutil (+ retrait du ScreenUtilInit de main.dart, plus aucun .w/.h/.sp/.r dans le code), pretty_dio_logger et injectable retirés du pubspec. Correction de l’audit : freezed est réellement utilisé (SelectionRequirementModel / skill_selection_cubit) → conservé.
  • Clé Reverb prod hardcodée dans socket_constant.dart (pas un vrai secret, mais à déplacer vers un --dart-define par cohérence)
  • (02/07) owner_home_screen.dart:559 : fallback enterprise?.address ?? currentUser?.address pour valider l’adresse avant publication de mission — hypothèse obsolète depuis le retrait de la synchro entreprise/user côté back (commit 0f5a1a5) : l’adresse user ne reflète plus celle de l’entreprise
  • (02/07) L’API INSEE back (get-enterprises-by-siren, pré-remplissage création d’entreprise) n’est pas encore consommée par le front — saisie manuelle uniquement. ✅ Fait 09/07/2026 : EnterpriseCubit.lookupBySirengetEnterprisesBySiren (sirenLookupStatus), consommé par owner_add_enterprise_screen et owner_edit_enterprise_screen, avec repli saisie manuelle si l’API INSEE échoue.

1.12 Migration iOS UIScene lifecycle (échéance Apple) Important

Section titled “1.12 Migration iOS UIScene lifecycle (échéance Apple) Important”

Noté le 17/06/2026 — warning apparu à la compilation : « To ensure your app continues to launch on upcoming iOS versions, UIScene lifecycle support will soon be required. Please see https://flutter.dev/to/uiscene-migration for the migration guide ». Guide officiel : docs.flutter.dev/release/breaking-changes/uiscenedelegate.

Le contexte. Apple déprécie le cycle de vie UIKit basé sur UIApplicationDelegate au profit de UIScene. Concrètement : une app buildée avec le SDK iOS 27 et qui n’a pas adopté UIScene ne se lancera plus (crash au démarrage). Le SDK iOS 27 sort en beta à la WWDC (08/06/2026) et en version finale en septembre 2026. Ça ne casse rien sur les iOS actuels — c’est une échéance, pas un bug présent. Marge réelle : jusqu’à ce qu’on soit forcé de builder avec le SDK iOS 27 (≈ septembre 2026, plus la fenêtre Apple d’obligation de SDK à la soumission). À traiter dans la roadmap, pas en urgence — passe Urgent dès qu’on touche au build iOS 27 / Xcode 26.

Pourquoi Neeko est concerné (pas d’auto-migration). Flutter 3.41+ (on est en 3.41.8) migre automatiquement les projets dont l’AppDelegate est non modifié. Or ios/Runner/AppDelegate.swift est customisé : il set UNUserNotificationCenter.current().delegate = self et appelle GeneratedPluginRegistrant.register(with: self) dans didFinishLaunchingWithOptions. → l’auto-migration ne s’appliquera pas, la CLI émettra un warning et la migration sera manuelle.

Ce qu’il faut faire :

  1. ios/Runner/Info.plist : ajouter la clé UIApplicationSceneManifest (UIApplicationSupportsMultipleScenes = false, UISceneDelegateClassName = FlutterSceneDelegate) — absente aujourd’hui.
  2. AppDelegate.swift : sortir l’enregistrement des plugins de didFinishLaunchingWithOptions et le déplacer dans didInitializeImplicitFlutterEngine (protocole FlutterImplicitEngineDelegate) → GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry). Ne plus accéder au FlutterViewController dans didFinishLaunching (crash potentiel post-migration).
  3. Créer un SceneDelegate.swift (class SceneDelegate: FlutterSceneDelegate {}) si on veut gérer le lifecycle natif.

Point de vigilance — plugins lifecycle-sensibles. Les events lifecycle/openURL passent d’AppDelegate à la SceneDelegate. Trois plugins de Neeko en dépendent et doivent avoir adopté FlutterSceneLifeCycleDelegate côté natif au moment où on migre, sinon push/callbacks OAuth cassent en silence :

  • firebase_messaging (^16.2.0) — réception des push (le delegate UNUserNotificationCenter actuellement posé dans l’AppDelegate)
  • google_sign_in (^7.2.0) et sign_in_with_apple (^7.0.1) — callback openURL du flux OAuth

Avant de migrer : vérifier que les versions de ces 3 plugins déclarent le support UIScene (sinon attendre/bumper). Tester en build sur iOS 26 (Xcode 26) et vérifier que le warning a disparu + que push + login Google/Apple fonctionnent toujours.

1.13 Revoir la norme des commentaires ✅ Résolu 23/06/2026

Section titled “1.13 Revoir la norme des commentaires ✅ Résolu 23/06/2026”

Fix : bascule de la norme custom (// bandeau //// + Param:/Return:, non lue par l’IDE ni dart doc) vers dartdoc /// : markdown stylisé concis (titre > ###, gras pour les libellés, inline-code pour les types, [refs] cliquables ; côté UI, orienté usagece qu’affiche le widget + Utilisé dans : où/par qui). CLAUDE.md règle 5 réécrite. Migré sur repositories, cubits (méthodes), helpers/services/api/storage et toute l’UI (classes widgets + méthodes). Exclus : models/DTO et classes d’état (boilerplate). Au passage : 11 widgets orphelins supprimés + nettoyage du dead-code signalé par l’analyzer (if(false), dead_null_aware, imports/champs inutilisés).

1.14 Push owner multi-enterprise cassés ✅ Résolu (vérifié 09/07/2026, commit « découplage préliminaire auth / entreprise »)

Section titled “1.14 Push owner multi-enterprise cassés ✅ Résolu (vérifié 09/07/2026, commit « découplage préliminaire auth / entreprise »)”

Contexte du bug (audit 02/07) : le back poussait les owners via enterprise.device_token par entreprise et update-owner-token ne mettait à jour qu’une entreprise ; côté front, _registerOwnerFcmToken n’était déclenché que par le BlocListener<EnterpriseCubit> (jamais au boot → token jamais enregistré) et un guard one-shot _ownerTokenRegistered + enterpriseId capturé figeaient le token sur la première entreprise.

Fix : le token est passé sur une route unique rattachée au user (updateDeviceToken / UpdateDeviceTokenRequest, back en upsert additif + fan-out sur toutes les entreprises/managers au push) et enregistré dès initState du scaffold (_updateFCMToken, owner_main_scaffold.dart), avec rebranchement de onTokenRefresh. Plus d’enterpriseId dans la boucle, plus de guard one-shot. Le BlocListener<EnterpriseCubit> restant ne sert plus qu’à rafraîchir le feed accueil. ⚠ Dépend du changement back correspondant (device_token rattaché au user, pas à l’entreprise).

1.15 Multi-enterprise : incohérences d’état résiduelles Moyen

Section titled “1.15 Multi-enterprise : incohérences d’état résiduelles Moyen”

Le chantier multi-enterprise (UI switcher, EnterpriseCubit, dashboard par établissement) est livré mais l’audit du 02/07 relève 5 trous, tous vérifiés :

  • owner_dashboard_screen.dart:257 : le bouton « Réessayer » des stats recharge activeEnterprise?.id au lieu de widget.enterpriseId — consulter le dashboard d’un établissement non-actif + retry = stats d’une autre entreprise affichées.
  • EnterpriseCubit.hydrate n’est appelé qu’au bootstrap (session_cubit.dart:79) et aucun refresh ultérieur ne ré-hydrate le segment enterprises (profile_cubit.dart:192 jette le segment via identityOnly()) : une entreprise fermée/modifiée côté back reste affichée jusqu’au relaunch.
  • mission_feed_cubit.dart:198 : le socket owner insère toute nouvelle mission dans le feed sans filtrer par mission.enterpriseId — le feed filtré par entreprise active se pollue avec les missions des autres établissements.
  • EnterpriseCubit.closeEnterprise n’a aucun appelant UI : la fermeture d’entreprise (route back close-enterprise, 422 si mission in_progress) est inatteignable pour l’utilisateur. ✅ Fait 10/07/2026 : bouton rouge plein largeur (contour AppColors.error + icône corbeille, style destructif du design system) « Supprimer l’entreprise » en bas de owner_edit_enterprise_screen → modal de confirmation destructive → closeEnterprise. Succès : toast + dépile édition et dashboard (retour à la grille, pas de dashboard orphelin) ; 422 « mission en cours » remonté en toast d’erreur.
  • active_enterprise_switcher.dart:38 : buildWhen ne compare que id + length → un renommage/changement d’adresse de l’entreprise active ne rafraîchit pas la pill. Mineur.

1.16 FeedbackDialog : envoi possible avec 0 étoile Moyen

Section titled “1.16 FeedbackDialog : envoi possible avec 0 étoile Moyen”

feedback_dialog.dart:35/288 : _rating démarre à 0 et ENVOYER n’a aucun guard sur la note → le back répond 422 (rating => min:1), mais l’appel est fire-and-forget (owner_home_screen.dart:370-378, erreur ignorée) et le dialog se ferme : l’avis est perdu en silence. Fix : désactiver le bouton tant que _rating == 0 + afficher l’erreur du cubit.


Section remise à jour au 10/06/2026 après audit complet (gros refactor entre mars et juin : God controllers éclatés en services, ~488 tests posés, sockets revus (naming + retrait des ShouldBroadcastNow inutiles), queues passées sous Horizon, phpstan level 5 sans baseline).

2.1 God controllers MissionController / UserController ✅ Résolu 10/06/2026

Section titled “2.1 God controllers MissionController / UserController ✅ Résolu 10/06/2026”

Fix : Refactor progressif (commits refactor complet du get, refactor User terminé, fin refactor + test updateMission…). MissionController 403 lignes, UserController 228 : devenus des wrappers validation + délégation vers les services (MissionService + traits, MissionAssignmentService, MissionBillingService, UserService…). Plus de logique métier ni de queries Eloquent inline.

2.2 EnterpriseService : CRUD toujours dans le controller ✅ Résolu 12/06/2026

Section titled “2.2 EnterpriseService : CRUD toujours dans le controller ✅ Résolu 12/06/2026”

Fix : EnterpriseService::create() (geocoding inclus), update() (champs présents + re-geocoding + propagation position) et updateStorefront() (ImageService injecté — plus aucun app() sur ce domaine) créés ; le controller est réduit à validation + gardes HTTP + délégation. Couverture posée : EnterpriseServiceTest (16 tests unitaires, collaborateurs mockés) + AddressGeocodingTestTest (la route publique n’avait aucun test). Refactor validé sans toucher les tests de route existants.

destroy → closeEnterprise (12/06 après-midi) : le delete d’entreprise est devenu une fermeturePOST enterprises/{id}/closeEnterpriseService::closeEnterprise() qui clôt proprement les missions actives via closeEveryMissions (open → annulation raison 8, matched → release du worker, waiting_feedback → avis auto 4/5 ; une in_progress bloque tout en 422) puis archive (inactive=true, pas de delete : l’historique des missions garde son contexte établissement, vérifié par test). Le flag inactive est filtré en opt-in via le scope active() sur les 8 surfaces owner (index/show/update/storefront/token, résolution d’enterprise à la création de mission, segment enterprises de get-profile) — pas de global scope, l’admin et l’historique voient tout. CloseEnterpriseTest (9 tests) remplace DestroyEnterpriseTest. ⚠ Front : route et sémantique changent (la fermeture annule les missions ouvertes au lieu de refuser).

Restes assumés : updateOwnerToken() et index()/show() restent inline (un seul statement chacun — sous le seuil “une ligne de métier”).

Purge de compte alignée (12/06 soir) : HandlesHardDeletion n’efface plus les enterprises — elles sont archivées (inactive=true) et réassignées au ghost AVANT le forceDelete (sans quoi la FK owner_id ON DELETE CASCADE les emporterait — piège attrapé par test). Les missions gardent leur enterprise_id, les enterprise_stats survivent. Décision : pas de scrub des champs perso pour le moment (manager/email/phone/device_token restent sur la ligne archivée) — à réévaluer si la question RGPD se pose sérieusement.

2.3 Validation dupliquée : 74 Validator::make, 0 FormRequest → Convention inline actée, reste l’harmonisation des formats Mineur

Section titled “2.3 Validation dupliquée : 74 Validator::make, 0 FormRequest → Convention inline actée, reste l’harmonisation des formats Mineur”

Décision 12/06/2026 : pas de migration FormRequest. La validation inline Validator::make() + sendError est la convention du projet. Raisons : (a) l’enveloppe d’erreur custom ({success: false, message: première erreur}) obligerait une BaseFormRequest qui override failedValidation() pour recréer sendError ; (b) authorize() ferait doublon avec les middlewares ; (c) 74 call sites à migrer pour un gain purement esthétique, alors que ~690 tests de route épinglent les contrats actuels ; (d) les règles répétées (mission_id => required|integer|exists) sont le contrat propre de chaque route, pas une duplication à factoriser — les coupler créerait du couplage sans concept partagé (même arbitrage que le refus du service de lecture missions).

Reste à faire (la seule vraie anomalie) : les formats horaires divergent entre endpoints — create-mission-v2 valide start_time/end_time en H:i quand update-my-mission et admin/update-mission-details exigent H:i:s. Le front doit envoyer deux formats pour le même champ. Harmonisation tolérante possible sans casser l’app : date_format:H:i:s,H:i des deux côtés (multi-format supporté depuis Laravel 9), puis resserrer côté front à l’occasion.

2.4 getUsersAdmin : N+1 queries ✅ Résolu 18/03/2026

Section titled “2.4 getUsersAdmin : N+1 queries ✅ Résolu 18/03/2026”

Fix : Eager loading avec User::with(...). commit cce33293eccad614101fc59fa0be3ef4d1858344

Cette route admin fait autant de requêtes SQL que d’utilisateurs (1 requête par user dans une boucle). Avec 200 users → 201 requêtes. Il faut passer sur de l’eager loading (User::with(['workerProfile', 'enterpriseProfile'])->get()) pour descendre à 3 requêtes max.

2.5 Système centralisé d’erreur / logging ✅ Résolu 08/04/2026

Section titled “2.5 Système centralisé d’erreur / logging ✅ Résolu 08/04/2026”

Fix : Ajout de 200/300 logs de type notice, warning et error. AJout d’un handler qui transmet au système de monitoring GlitchTip. Logs sur 30jours dans des fichiers séparés + stdout

Les erreurs sont gérées à la volée lors des appels de services, commandes, controllers, etc. Trois chantiers :

  • Exception Handler Laravel : renvoyer du JSON propre au lieu du HTML par défaut quand une erreur non attrapée survient
  • Logging persistant : écrire les logs dans un volume monté, pas dans le container éphémère Docker
  • Monitoring : intégrer un service type Sentry pour le suivi en temps réel

8 models utilisent $guarded = [], ce qui autorise le mass assignment sur n’importe quel champ : TokenLedger, XpLedger, GlobalStats, EnterpriseStats, EnterpriseRoleStats, MissionStats, OwnerStats, WorkerStats. Même si ces tables sont peuplées par les jobs (pas d’input user direct), c’est un problème en devenir. Il faut les passer en $fillable explicite. (~30 min)

2.7 Découplage services : app() restants Mineur

Section titled “2.7 Découplage services : app() restants Mineur”

Gros progrès sur le découplage des services racine, mais il reste des contournements app() qui masquent des dépendances circulaires :

  • NotificationsService:243app(FeedbackService::class) (le contournement historique, toujours là)
  • FeedbackServiceapp(StatsService), app(RankService), app(MissionService)
  • Idem ponctuellement dans StatsService, StripeService, ChatService, TokenOfferService

2.8 Couverture de tests : bonne base, trous ciblés ✅ Résolu 15/06/2026 Important

Section titled “2.8 Couverture de tests : bonne base, trous ciblés ✅ Résolu 15/06/2026 Important”

État au 15/06/2026 : ~800 tests (Feature/Http + Feature/Services). La qualité est très bonne : assertions DB réelles, events vérifiés avec leurs paramètres, tests anti-fuite de données, Stripe correctement fake (y compris le SDK stripe-php via un faux HttpClient injecté), helpers CreatesUser qui répliquent le vrai flow signup.

Bien couvert ✅ : cycle de vie mission complet, tokens/Stripe (service + webhook signé), workers, enterprise, users, admin (~120 tests sur les 6 controllers admin + CrossCutting 401/403 systématique), routes Token, Auth (loginV2/signupV2/logout/onboarding + AuthService), et depuis le 15/06 Chat (controller + service, 35 tests), Notifications (routes + service, 21), Feedback (give-feedback + service, 14), Matching (les 2 sens du pipeline : statut/zoning/declined/skills/blacklist, 11), Rank (RankService + route ranks, 18) — posés en parallèle par 4 sous-agents, un par section.

Tous les trous prioritaires d’origine sont fermés. Reste, et on s’en passe : googleLogin/appleLogin/appleCallback — la seule partie non testée, volontairement écartée : la vérification du token exige de forger des JWT signés / mocker Google_Client (instancié en dur), beaucoup d’effort pour tester de la délégation à des SDK. La logique métier qui suit (findOrCreateUser) reste testable si le besoin remonte.

Détail : le trait tests/Concerns/UsesRedis est utilisé (AppSetting zoning/tension, TensionMap). Fix au passage de EnterpriseProfileFactory (owner fantôme à chaque makeOwner()). Bugs trouvés en écrivant les tests et corrigés : ChatService gardait sur 'cancelled' au lieu de 'cancel' (enum réel) → le chat d’une mission annulée restait ouvert ; double update(read_at) dans NotificationsService::markAsRead.

2.9 Le typage global du projet est bof — ✅ Résolu 10/06régression 02/07 Important

Section titled “2.9 Le typage global du projet est bof — ✅ Résolu 10/06 → régression 02/07 Important”

Fix 10/06 : phpstan level 5 actif sans baseline, signatures des services typées (paramètres + retours). Properties des models set.

Régression constatée le 02/07/2026 : 41 erreurs phpstan (et le run par défaut crash à la limite mémoire 128M — lancer avec --memory-limit=2G). Concentrées sur : TensionCacheService/TensionMapService (signatures Redis::pipeline, getAroundByMissionIds appelé avec 2 params, lat/lng string|null passés en float), StatsController (propriétés d’agrégats non déclarées), VerifyController (classe UserVerify inconnue, type de retour invalide), jobs notification (addHours(float) — vrai bug, cf. 2.19), code mort (LookingController:46-50 unreachable, EnterpriseService:258 comparaison toujours fausse, MissionController:263). phpstan ne tourne plus systématiquement avant push → à repasser à 0 et à verrouiller en CI (cf. plan-cicd-tests.md).

2.10 Endpoints admin sans middleware admin ✅ Résolu 10/06/2026

Section titled “2.10 Endpoints admin sans middleware admin ✅ Résolu 10/06/2026”

Fix : $this->middleware('admin') ajouté sur AdminStripeController (refund — la partie critique), AdminTokenOfferController, et ->only('getGlobalStatsAdmin') sur StatsController. Vérifié par audit croisé routes admin/* ↔ controllers : plus aucune route admin non protégée (les controllers mixtes Token/Stripe/Report/Stats protègent leurs méthodes admin via ->only()).

11/06/2026 : les tests 403 correspondants sont posés (CrossCutting 401/403 sur toutes les routes admin, dont refund et token-offers) — le filet qui aurait attrapé le trou d’origine existe désormais. Point entièrement clos.

Trous d’origine : n’importe quel user authentifié pouvait appeler POST admin/stripe/refund (remboursements), le CRUD des token offers et admin/global-stats. Reliquat mineur : les checks role !== 'admin' inline dans AdminStripeController/AdminTokenOfferController sont redondants avec le middleware → à purger à l’occasion (voir 2.16).

2.11 Race condition sur le double-accept de mission ✅ Résolu 15/06/2026 Critique

Section titled “2.11 Race condition sur le double-accept de mission ✅ Résolu 15/06/2026 Critique”

Fix : MissionAssignmentService::workerAcceptMission enferme la section critique (verrou + re-check + create) dans DB::transaction(fn() => { Mission::lockForUpdate()->find(id); throw si un assignment existe; create; }). Le lockForUpdate sur la ligne mission sérialise les accepts concurrents — le 2ᵉ worker bloque, reprend, voit l’assignment, throw MissionAlreadyTakenException (→ 422 propre au lieu d’un 500). Subtilité : la transaction n’est pas là pour le rollback (un seul insert) mais pour maintenir le verrou vivant sur les 3 statements — hors transaction, l’autocommit MySQL relâche le FOR UPDATE aussitôt et le lock devient un no-op. Seule la section critique est transactionnelle ; missionMatched (broadcasts/Redis/job) tourne après commit pour éviter le hazard broadcast-puis-rollback. Couvert par MissionAssignmentServiceTest (double-accept → exception). 2.12 pour ce flow reste ouvert : rendre tout le chaînage atomique exigerait de passer les side-effects de missionMatched en afterCommit — chantier séparé.

2.12 Transactions manquantes sur les flows critiques Important#1 dette back restante (15/06)

Section titled “2.12 Transactions manquantes sur les flows critiques Important — #1 dette back restante (15/06)”

Vérifié code en main le 15/06/2026 : aucun DB::transaction dans MissionBillingService ni StripeService. C’est le seul vrai trou de correctness restant côté back, et il touche l’argent.

Le cœur du problème, c’est l’absence totale de filet en cas de cassure au milieu du flux paiement. Ces flows enchaînent plusieurs écritures non atomiques : si le process meurt entre l’étape 1 et l’étape 2 (timeout, OOM, exception, redéploiement, perte de connexion DB), il n’y a aucun rollback, aucun compensating action, aucun retry idempotent, aucun trigger de réconciliation. L’état reste incohérent de façon permanente — personne ne le rattrape, et ça se solde par un ticket support (« j’ai payé/été débité, je n’ai rien »).

  • StripeService::onInvoicePaid (le plus grave — argent réel client) : Payment::create() puis crédit des tokens (TokenService::buy). Crash entre les deux → le client a payé chez Stripe, le Payment est enregistré, mais ses jetons ne sont jamais crédités. Le webhook Stripe ne sera pas rejoué (on a déjà répondu 200, et l’idempotence interne re-skippera car le Payment existe). Argent encaissé, contrepartie jamais livrée, aucun mécanisme pour le détecter.
  • MissionBillingService::payAndCreateMission : TokenService::spend() (transactionnel en interne ✅) puis createAndOpen() en 2 transactions séparées → crash entre les deux = jeton débité sans mission créée. Le worker a payé l’ouverture d’une mission qui n’existe pas ; rien ne le rembourse.
  • MissionAssignmentService::workerAcceptMission : depuis le 2.11, la création d’assignment est sous transaction + lock ✅, mais le reste (missionMatched → chat → stats) tourne hors transaction (volontaire, pour ne pas exposer broadcasts/Redis à un rollback) → assignment orphelin possible si missionMatched throw. Moins grave (pas d’argent), fail-safe (pas de double-accept).

Fix : envelopper les écritures DB de chaque flow argent dans un DB::transaction (rollback automatique sur exception). Pour onInvoicePaid/payAndCreateMission c’est direct (pas de side-effect externe à sortir, contrairement à workerAcceptMission). Tester explicitement le rollback : injecter une exception après l’étape 1, asserter qu’aucune des deux écritures ne subsiste. ~1-2h chacun. À faire avant de scaler les paiements.

2.13 getMissionDetails accessible sur n’importe quelle mission ✅ Résolu 15/06/2026 Important

Section titled “2.13 getMissionDetails accessible sur n’importe quelle mission ✅ Résolu 15/06/2026 Important”

Fix : gate d’accès dans getMissionDetails : open reste consultable par tout user authentifié (feed worker) ; pour les autres statuts, accès réservé à l’admin, à l’owner de la mission, ou à un worker ayant déjà eu un assignment avec cet owner — réutilisation de MissionAssignmentService::thereWasAssignmentBetween (withTrashed, donc historique soft-deleted inclus), sinon 403. Choix assumé : le contrôle est owner-based, pas mission-based — un worker ayant bossé une fois pour un owner voit les autres missions non-open de cet owner. L’over-grant est borné (il faut un vrai assignment passé pour débloquer un owner), il tue le harvesting de masse par compte neuf, et il évite de dupliquer le prédicat. Le check DB n’est évalué qu’en dernier recours (court-circuit ||, hot path open intouché). Couvert par GetMissionDetailsTest (open ouvert, 403 non-lié, owner/admin/ex-worker-trashed OK).

2.14 Format de réponse API : généraliser les Resources Important

Section titled “2.14 Format de réponse API : généraliser les Resources Important”

Le refactor apiResource est entamé mais très peu déployé. Mesuré au 10/06/2026 : 114 sendResponse() dans les controllers, 1 seul passe par une Resource (~5% d’adoption en comptant les variantes pagination).

Avancée 11/06/2026 — tranche “admin Mission” faite, avec la méthode préconisée ci-dessous (Resource + test qui épingle les clés + type front aligné) : getMissionsAdmin et getMissionFromUserAdmins (réécrits : pagination 5/10 par page + nbMissions, filtres status[]), updateMissionDetails (recalé sur le contrat owner, rend MissionResource) et getAssignmentsAdmin (MissionAssignmentResource + worker en UserResource — l’ancien retour sérialisait le User brut, token_balance compris : fuite colmatée). Au passage, l’endpoint admin/get-missions-with-idrole et sa méthode ont été supprimés (remplacés par admin/get-missions-from-user).

Quantité de boulot réelle — les 114 ne sont pas tous à convertir :

  • ~40% sont des acks (['mission_id' => x]), de la config ou des stats agrégées → pas de conversion (règle : seules les entités sérialisées passent par une Resource)
  • ~25 endpoints admin → consommés par le webadmin uniquement, conversion optionnelle (hygiène, pas bloquant)
  • Le cœur du chantier : ~30-40 call sites sur 7 entités : Mission, User, Enterprise, Notification, Token/Ledger, Feedback, Chat

Méthode : c’est l’autre moitié du point 1.2 front — un seul chantier vertical, par entité, pas par couche. Chaque tranche = Resource appliquée partout pour l’entité + test qui épingle les clés + model Dart aligné + pont ancien→nouveau supprimé. Une tranche = 0,5 à 1 jour, shippable indépendamment. Total cœur : 1 à 2 semaines au fil de l’eau. Ordre : Mission (Resource déjà prête) → User (pattern enveloppe déjà en place sur get-profile) → Enterprise → le reste par ordre de douleur front.

Compat versions mobiles : réglée par le gate de version côté app (blocage connexion sous version X + message de maj forcée) → pas besoin de maintenir l’ancien format pendant la transition.

Avancement 02/07/2026 : 116 sendResponse() / 19 usages de Resources (vs 114/1 au 10/06) — UserPrivate/UserPublic/EnterprisePrivate/Mission/MissionAssignment/Rank couvrent les payloads principaux user/enterprise/mission. Reliquat repéré à l’audit : createEnterprise renvoie encore le Model brut (EnterpriseController.php:96, clé frontPictureUrl en camelCase) alors qu’update/storefront rendent l’EnterprisePrivateResource — à aligner.

Conventions à respecter sur User (3 audiences) : UserResource = noyau public unique ; vue “soi-même” = enveloppe segmentée (user + segments privés, déjà le pattern de get-profile) ; vue admin = AdminUserResource extends UserResource. Jamais de when() pour de la visibilité sécurité, le choix de la vue se fait au controller.

Au passage, sendPaginationResponse() ne renvoie que nextpage (pas de total/per_page) — la pagination native Laravel ferait mieux.

Addendum 15/07/2026 — rank : sortir la greffe post-resolve, aligner le privé. getUserPublicProfile a été nettoyé : le rang n’est plus greffé à la main sur le tableau résolu ($data["rank"] = rankService->getWorkerRank(...), + requête WorkerProfile redondante) — supprimé, avec sa méthode RankService::getWorkerRank et ses tests. Il est désormais exposé imbriqué et conditionnel via WorkerProfilePublicResource ('rank' => new RankResource($this->whenLoaded('rank'))), le controller eager-loadant workerProfile.rank. Reste à aligner le flux privé : WorkerProfileService::getWorkerFullPrivateProfile greffe encore current_rank/next_rank sur un tableau de contexte (WorkerProfileService.php:74/81) au lieu de laisser WorkerProfilePrivateResource porter le rang via whenLoaded — même dette, même correctif (current_rank → relation dans la resource ; next_rank, lui, reste calculé côté service car ce n’est pas une simple relation). ⚠️ Rupture de contrat (cf. 1.2 front) : sur getUserPublicProfile, rank est passé de data.rank (top-level) à data.worker_profile.rank, et le bloc worker_profile apparaît désormais dans la réponse (null si la cible est un owner).

2.15 Durcissement routes & throttling Moyen

Section titled “2.15 Durcissement routes & throttling Moyen”
  • enterprises/geocoding-test est hors groupe auth → appels Google Geocoding gratuits pour n’importe qui (coût). À protéger ou supprimer. ✅ Résolu 09/07/2026 : la création d’entreprise étant devenue post-signup (endpoint dédié create-enterprise), la route n’a plus besoin d’être publique — déplacée dans le groupe auth:api/banned/verified/onboarding, except() retiré du controller, tests passés en actingAs + cas 401.
  • test-error publique → à retirer en prod.
  • Pas de throttle dédié sur loginV2 / google / apple : seul le 60 req/min global s’applique, large pour du brute-force de mot de passe. Un throttle:5,1 sur le login ne coûte rien. Idem pour stripe/create-intent (frais Stripe).
  • Jobs sans failed() handler (aucun sur les 10 jobs). Horizon montre les failed jobs dans son dashboard, mais un failed() qui remonte vers GlitchTip sur SyncMissionMatching et les push serait cohérent avec le monitoring.
  • Mission::getRequirementsAttribute : bug fonctionnel fixé le 11/06/2026 — l’accessor lisait la colonne CSV legacy missions.requirement_id que plus rien n’alimente depuis createMissionV2 → requirements toujours vides dans toutes les réponses (c’était pire que le simple N+1 noté ici). Il résout maintenant depuis mission_requirements (+ test de régression). Reste le N+1 d’origine : toujours dans $appends, 2 queries par mission sérialisée → à passer en vraie relation belongsToMany (qui porterait aussi la priority du pivot) ou à retirer des appends. Décision 11/06/2026 : on garde la sérialisation en model brut pendant le refactor back/front (une RequirementResource imposerait le chargement manuel partout + compat webadmin). Conséquence assumée : clé imageUrl en camelCase et toutes colonnes exposées sur le wire — le front V2 (modelsV2/requirement.dart) épingle ce format. À reprendre après le chantier 2.14.
  • Colonne worker_profiles.skills morte-vivante ✅ Résolu 12/06/2026 : colonne droppée par migration, retirée du model et de la Resource ; les skills passent par worker_skill_requirements.
  • Colonne missions.requirement_id définitivement morte (plus écrite depuis la suppression de l’ancien update admin, plus lue depuis le fix ci-dessus) → migration de drop + nettoyage $fillable/@property, même traitement que tension_flag.
  • (06/07) Tables experiances et degrees mortes de bout en bout — à enlever. Vérifié sur les 3 couches : back, elles ne sont lues que par GET get-dropdown (BaseController::getDropdown) et aucun code n’écrit jamais worker_profiles.experience_id/degree_id (fillable + exposées dans WorkerProfilePublicResource, mais NULL pour tout le monde) ; front Flutter, les listes sont fetchées/cachées (register_cubit, skill_selection_cubit avec selectDegree/selectExperience jamais appelés par aucun widget) mais jamais affichées ni soumises (submitSkills n’envoie que les skills) ; webadmin, seulement des types TS, aucun rendu. Ordre de démontage : front d’abord (retirer le cache degrees/experiences + strings l10n orphelines « Diplôme » — garder l’endpoint tant que de vieilles versions de l’app appellent get-dropdown au register), puis back (route, modèles Degree/Experiance, drop des 2 tables + colonnes experience_id/degree_id, Resources/@property), puis types webadmin.
  • Code mort tension_flag dans AdminUserController ✅ Résolu 12/06/2026 : updateUserDetailsAdmin recalé sur UserService::updateProfile (mêmes champs que le user, propagation enterprises incluse) — le bloc tension_flag/enterprise inline a disparu avec le refactor.
  • Checks role !== 'admin' inline redondants avec le middleware admin dans AdminStripeController/AdminTokenOfferController (couverts par les CrossCutting 403) → à purger à l’occasion.
  • Relations dépréciées encore présentes sur User (worker_details(), missions()) → 02/07 : plus aucun appelant dans app/tests/routes/database — supprimables sans risque.
  • Channel chat.{chatId} : fenêtre TOCTOU théorique entre l’auth du channel et l’envoi (atténuée par le double check dans ChatController).
  • (02/07) Route de debug GET /preview/welcome-restaurant publique dans routes/web.php:47, marquée « TODO: à retirer avant commit » et committée quand même — à supprimer.
  • (02/07) toggleBan owner (UserAdminService.php:70) : closeEveryMissions exécuté avant la pose du ban, sans transaction ni catch — un échec en milieu de cascade laisse des missions annulées sans ban posé (500 générique).
  • (02/07) Push « 30min avant mission » : « soyez à l’heure et polie ! » (NotifyBeforeMissionStartJob.php:50) — accord féminin systématique, visible par tous les workers.
  • (02/07) loginV2 lit $request->entreprise_id (orthographe FR, AuthController.php:73) — seule occurrence de cette clé dans tout le back, jamais envoyée par le front : branche morte à corriger en enterprise_id.
  • (02/07) Dépendances : CVE low symfony/yaml (un composer update suffit), laravelcollective/html abandonné (→ spatie/laravel-html), Laravel 10.50 / stripe-php 17 / passport 12 en retard d’une ou deux majors — au fil de l’eau.
  • (14/07) Matching worker relancé à chaque ping GPS Mineur long terme. getMatchingMissionsForWorker (feed worker) est redéclenché via le job FindMissionsForWorker non seulement à la mise en ligne (putWorkerReady) mais à chaque update de position tant que ready_to_work=1 (WorkerProfileService::updateWorkerPosition:114) → le pipeline complet (Redis GEORADIUS + ~6 requêtes SQL + zoning haversine PHP) retourne à chaque déplacement. À l’échelle actuelle c’est bénin : le job est asynchrone (onQueue('matching'), ne bloque pas la requête worker — sauf le chemin de pull synchrone WorkerController:321), et le haversine PHP sur ~150 missions est négligeable (sub-milliseconde, ce n’est pas le coût). Le vrai levier le jour où ça pique = fréquence × nb workers × ~6 requêtes, à traiter par un débounce des updates de position côté front (pas en touchant au zoning). Sous-jacent à revoir aussi : applyZoningFilter recalcule âge/tier/haversine en PHP, ce qui duplique la logique déjà présente dans RedisGeoService (getTierForMission/getRadiusForMission) — un helper unique côté RedisGeoService serait plus DRY, mais c’est un chantier séparé (touche au découpage service). Contexte archi : le zoning post-Redis est indispensable côté worker car Redis pré-filtre au rayon max (tier3/45 km) faute de pouvoir varier le rayon par mission selon son âge en une seule requête — ce n’est donc pas de la redondance, juste une frontière perf/métier à assumer.

2.17 Path traversal sur images/delete : suppression de fichier arbitraire Critique — audit 02/07

Section titled “2.17 Path traversal sur images/delete : suppression de fichier arbitraire Critique — audit 02/07”

DELETE /api/images/delete accepte un path libre ('path' => 'required|string', ImageController.php:82-84) passé tel quel à ImageService::delete. resolveFullPath (ImageService.php:178-185) ne neutralise pas les ../ : un path /storage/../../../.env résout en base_path('.env') et File::delete le supprime ; la branche fallback public_path($path) traverse tout autant. Aucune vérification d’ownership non plus : n’importe quel user authentifié peut supprimer l’image de n’importe qui — ou n’importe quel fichier accessible en écriture par PHP. Fix : basename()/realpath contraint au répertoire d’upload + vérifier que le fichier appartient bien à l’appelant. À faire immédiatement.

2.18 Contrôles métier manquants : feedback, accept-mission, create-mission Important — audit 02/07

Section titled “2.18 Contrôles métier manquants : feedback, accept-mission, create-mission Important — audit 02/07”

Trois endpoints laissent passer des états que le métier interdit (tous vérifiés exploitables) :

  • POST /give-feedback (FeedbackController.php:34-52) : valide seulement mission_id => exists et recipient => integer (même pas exists:users,id). Ni controller ni FeedbackService::create ne vérifient que l’appelant est owner/worker de cette mission, que la mission attend un feedback, ni que le recipient est la contrepartie → n’importe quel compte peut noter n’importe qui sur n’importe quelle mission, et par effet de bord force-compléter des missions arbitraires. Le plus urgent des trois.
  • worker/accept-mission (WorkerController.php:48-80) : vérifie l’absence d’assignments mais jamais status === 'open' ni l’éligibilité du worker (rôle, mission proposée) → accept possible sur une mission cancel/done sans assignment.
  • create-mission-v2 (MissionController.php:64 + HandlesMissionPrimitives.php:41) : enterprise_id => exists sans check d’ownership ; un id appartenant à un autre owner est silencieusement ignoré (mission créée avec enterprise_id=NULL au lieu d’un 403/422). En prime, $request->all() (pas validated()) descend jusqu’à Mission::create → mass assignment de champs jamais validés (mission_start_time, reason_id, note, last_report…).

2.19 Cycle expiration/relance des missions : 4 défauts sur le nouveau système ✅ Résolu 02/07/2026 Important

Section titled “2.19 Cycle expiration/relance des missions : 4 défauts sur le nouveau système ✅ Résolu 02/07/2026 Important”

Fix (02/07, même jour que l’audit — 4 fichiers : les 2 jobs + traits HandlesMissionPrimitives/HandlesMissionLifecycle, 810 tests verts, phpstan propre dessus).

  • Mission expirée = token owner perdu ✅ : ExpiredMissionCleanupJob passe désormais par MissionBillingService::closeAndRefundMission (au lieu de closeMission direct) → le token dépensé à la création est recrédité, comme sur l’annulation manuelle.
  • Chaînes de relance dupliquées au reopen ✅ : garde d’époque — un compteur mission:{id}:overdue_epoch (Cache/Redis) incrémenté à chaque openMission ; chaque OverdueMissionNotificationJob porte l’époque de sa naissance et s’auto-annule à son réveil si elle ne correspond plus à l’époque courante. Tue les chaînes héritées d’une ouverture précédente (un job delayed déjà en file ne pouvant pas être annulé). Alternative écartée : colonne DB (migration + @property pour un compteur purement technique) ; ne rien faire (spam de push assumé).
  • Backoff cassé ✅ : re-dispatch en addMinutes((int) round($h * 60)) au lieu de addHours(float) (Carbon 2 tronque la partie fractionnaire) ; BASE_DELAY_HOURS remis à 2 pour que le dispatch initial (+2h) et la suite (+3h, +4h30…) soient monotones et cohérents.
  • Report de date = mission zombie ✅ : updateMission reprogramme ExpiredMissionCleanupJob sur le nouvel horaire quand date/start_time change sur une mission openuniquement si ce début est futur (re-armer une expiration dans le passé n’a pas de sens). Le job initial devenu périmé tire à l’ancien horaire, voit now < nouveau start et ne fait rien.

2.20 Stripe multi-enterprise : un seul Customer par user, TVA figée ✅ Résolu 12/07/2026 Important — audit 02/07

Section titled “2.20 Stripe multi-enterprise : un seul Customer par user, TVA figée ✅ Résolu 12/07/2026 Important — audit 02/07”

Fix (12/07 — table polymorphe stripe_customers). Le mapping cus_xxx quitte users.stripe_customer_id (colonne droppée) pour une table polymorphe stripe_customers (stripe_customer_id, billable_type, billable_id) : une identité de facturation par entité payable. Aujourd’hui billable = EnterpriseProfile (B2B, un Customer par entreprise → nom/adresse/TVA propres, plus d’écrasement croisé) ; demain billable = User couvrira les achats perso worker (B2C) sans nouvelle plomberie. getOrCreateStripeCustomer lit/écrit désormais $enterprise->stripeCustomer (relation morphOne). Backfill migration : chaque cus_id existant rattaché à la première entreprise de son owner (invariant prod validé : personne n’a 2 entreprises à la bascule). up/down symétriques, table + colonne restaurables. 24 tests Stripe + admin verts.

Alternative écartée : refresh du tax_id à chaque Customer::update en gardant 1 Customer/user — ne corrige ni le nom/adresse écrasés à chaque switch, ni le mélange de 2 entités légales (TVA, factures, moyens de paiement) sous un même Customer.

Edge résiduel (mineur, hors scope de ce fix) : le tax_id reste créé create-only ; si le SIREN d’une entreprise change après création de son Customer, la TVA Stripe devient stale. En pratique un SIREN d’entité légale ne bouge pas, mais EnterpriseService::update autorise siren dans son allowlist → à décider séparément (retirer siren de l’allowlist, ou re-sync le tax_id au changement).

Rappel (contexte). createIntent était devenu multi-enterprise (enterprise_id requis, commit 229be65) mais le Customer restait unique par user, et le tax_id eu_vat n’étant créé que sur le chemin create, un achat pour l’entreprise B sortait la facture avec la TVA de l’entreprise A — document fiscal incorrect.

2.21 Update d’adresse enterprise : géocodage sans résultat écrase les coordonnées à NULL ✅ Résolu 12/07/2026 Moyen — audit 02/07

Section titled “2.21 Update d’adresse enterprise : géocodage sans résultat écrase les coordonnées à NULL ✅ Résolu 12/07/2026 Moyen — audit 02/07”

Fix (12/07 — EnterpriseService::update). Le résultat du géocodage n’est plus assigné aveuglément : lat/lng ne sont écrites dans $updateData que si getCoordinatesFromAddress renvoie un couple exploitable (isset($coords['latitude'], $coords['longitude'])). Sinon (retour null sans throw = BAN 0 résultat, ou exception réseau catchée) on préserve les coordonnées existantes + Log::warning. Effet de bord vérifié : shouldPropagatePosition ne se déclenchant que si lat/lng sont isset, un géocodage échoué ne propage plus rien à tort aux missions ouvertes. L’adresse texte, elle, reste mise à jour (seules les coords sont figées). create non touché : sur une entreprise neuve, ?? null n’écrase rien.

Rappel (inchangé, contexte). GeocodingHelper::getCoordinatesFromAddress retourne null sans throw quand la BAN ne trouve rien → le cas échappait au catch et $coords['latitude'] ?? null écrasait les coordonnées existantes par NULL en silence (une faute de frappe dans l’adresse suffisait), missions suivantes sans position → matching/zoning cassés.

2.22 Drift de compteurs stats sur échec open après finalize (création mission premium) Très mineur — 17/07/2026

Section titled “2.22 Drift de compteurs stats sur échec open après finalize (création mission premium) Très mineur — 17/07/2026”

Depuis le découpage de la création de mission en primitives (createMission nue → finalizeMissionCreation [requirements + stats] → openMission, orchestrées par MissionBillingService::createMissionWithBilling, avec compensation deleteMission), un chemin d’erreur laisse un léger surcompte.

Si openMission échoue après que finalizeMissionCreation a tourné (cas réel : owner sans enterprise ni coordonnées → InvalidArgumentException, cf. test missing_coordinates_returns_422 ; ou panne infra Redis), la compensation deleteMission (forceDelete) purge bien la mission + mission_requirements + mission_stats (FK ON DELETE CASCADE), mais les compteurs agrégés incrémentés par StatsService::onMissionCreatedOwnerStats.nb_missions_opened, EnterpriseStats.nb_missions_opened (+ moyenne avg_rate_per_hour), GlobalStats.total_missions_opened/total_missions_taken — ne sont pas liés à la mission par FK, donc pas décrémentés → surcompte de 1.

Portée très faible : chemin d’erreur uniquement ; un solde insuffisant échoue avant finalize (spend en premier) → zéro drift sur le cas courant. C’est de l’analytics (dashboards), pas d’argent ni de cohérence du flux mission, et onMissionCreated avale déjà ses propres erreurs (best-effort).

Fix envisagé (non fait, jugé pas prioritaire) : déplacer stats->onMissionCreated() après openMission (les stats ne compteraient que les missions réellement ouvertes → zéro drift, et plus juste sémantiquement). Coût : les stats sortent de la primitive intermédiaire finalizeMissionCreation (qui ne garderait que les requirements, nécessaires avant open pour le matching). Alternative écartée : inverse manuel des compteurs dans deleteMission — fragile (moyennes glissantes à dé-recalculer), diverge de onMissionCreated au moindre changement.

2.23 Premium : pas de période de grâce au renouvellement (trou de dunning) Urgent — 20/07/2026

Section titled “2.23 Premium : pas de période de grâce au renouvellement (trou de dunning) Urgent — 20/07/2026”

À traiter au moment du branchement Stripe ↔ premium, pas avant : le bug n’existe pas encore (aucun abonnement Stripe n’alimente premiums aujourd’hui, seul le stub dev premium/grant-self le fait), mais il sera présent dès la première ligne de webhook si on ne le prévoit pas.

User::getIsPremiumAttribute() est un prédicat purement date-based (premium?->expires_at?->isFuture()), et PremiumService::grant() positionne une échéance absolue (updateOrCreate, pas de cumul). Si le webhook d’abonnement ne prolonge expires_at que sur invoice.paid, alors entre l’échéance de la période et l’encaissement effectif, is_premium repasse à false : le client perd l’accès (403 sur create-enterprise, plus d’exemption de jeton dans MissionBillingService, plus de bonus Super dans le matching) alors qu’il est en règle. La fenêtre n’est pas théorique — Stripe étale ses retries de paiement (dunning) sur plusieurs jours, et même un paiement nominal peut arriver avec du décalage.

Fix envisagé : écouter aussi customer.subscription.updated et prolonger tant que le statut Stripe vaut past_due (source de vérité côté Stripe), ou à défaut appliquer une grâce fixe au grant. La première option est la plus juste ; la seconde est un filet acceptable si le webhook d’abonnement tarde.

Effet de bord positif à conserver : l’échéance absolue rend grant() idempotent — Stripe rejoue ses webhooks, un double appel ne doit pas doubler la période. Ne pas passer à un cumul (addMonth() sur l’existant) en cherchant à régler la grâce.

3.1 Installer supervisord dans le container de l’api ✅ Résolu 18/03/2026

Section titled “3.1 Installer supervisord dans le container de l’api ✅ Résolu 18/03/2026”

commit : f82a50a27869e35af567d63b5d84663b4958c6fc

actuellement le compose de l’api lance les services en vrac dans le container docker. Bien que plus propre que le legacy (rien), il serait préférable de passer sur un supervisord en process main qui contrôle, relance et monitor :

  • Nginx
  • Php-fpm (l’api)
  • Reverb
  • Cron -> Scheduler

Actuellement aucun docker ignore. C’est anecdotique mais c’est une optimisation à faire.

3.3 ajouter —isolated sur le compose api Mineur

Section titled “3.3 ajouter —isolated sur le compose api Mineur”

Afin d’éviter une concurrence entre 2déploiements (peu probable mais pas impossible) ajouter --isolated dans le docker de l’api.

Aujourd’hui on crée le serveur hetzner à la main, on installe coolify dessus, on ajoutes les autorisations git et les serveurs secondaires puis on lance le script du repo Infra qui lui va setup les envs, les clefs (dev, preprod, prod) etc. On ne peut réutiliser se script aprés coup, il sert uniquement à l’initialisation de départ de l’env.

Passer sur terraform et l’IAC permettrait un déploiement total via code, automatisé à 100%, et adaptable à la réalité actuel de l’infra (peut tout reconstruire si c pas bon, rebuild pour agrandir le serveur si on a besoin d’un plus gros, ne rien faire si tout est parfait).


4.1 Refonte de la gestion des fuseaux : tout en UTC côté serveur, traduction côté device Important — 20/07/2026

Section titled “4.1 Refonte de la gestion des fuseaux : tout en UTC côté serveur, traduction côté device Important — 20/07/2026”

Cible. Le serveur ne manipule, ne stocke et n’échange que de l’UTC ; le device traduit vers son propre fuseau, en entrée comme en sortie. Aujourd’hui c’est le serveur qui décide du fuseau du client, et il décide « Europe/Paris » pour tout le monde.

État actuel. config/app.php est bien en 'timezone' => 'UTC', mais le middleware global ConvertDatesToTimezone (groupe api, cf. app/Http/Kernel.php:45) intercepte toute JsonResponse et réécrit récursivement chaque date du payload en Europe/Paris au format Y-m-d H:i:ssans offset ni suffixe de fuseau (DateHelper::convertDatesInArray). Le client reçoit donc une heure murale française qu’il ne peut pas distinguer d’une heure locale à lui.

Trois problèmes distincts en découlent :

  1. Ambiguïté de sortie. Une chaîne sans offset n’est correcte que si le lecteur sait qu’il doit l’interpréter comme Paris. Un user dont le device est sur un autre fuseau (déplacement, fuseau réglé à la main) la lira comme locale et affichera un décalage. À noter : la conversion elle-même est correcte sur l’été/hiver — setTimezone('Europe/Paris') applique l’offset en vigueur à la date convertie (vérifié : UTC 12:00 → 13:00 en janvier, 14:00 en juillet). Le problème n’est pas le DST, c’est l’absence de marqueur de fuseau.
  2. Détection par regex, donc aveugle. DateHelper::isDateString matche ^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2} sur n’importe quelle chaîne du payload, sans connaître la sémantique du champ. Toute valeur datetime-shaped est présumée UTC et décalée — y compris une donnée déjà locale, ou un champ texte libre qui commencerait par une date. Le middleware ne peut structurellement pas faire la différence.
  3. Deux familles de colonnes temporelles aux sémantiques opposées. mission.date / start_time sont de l’heure murale Paris en base (non castées, hors regex car sans partie datetime complète → elles échappent au middleware), tandis que mission_start_time / mission_end_time / created_at sont castées datetime et sont converties. Deux conventions cohabitent dans la même table sans que rien ne le signale.

Rien n’est cassé aujourd’hui — l’app est mono-pays et la quasi-totalité des devices sont sur Europe/Paris. C’est de la dette de conception : le coût monte avec chaque nouveau champ temporel, et le jour où l’on ouvre hors de France, la correction devient une migration de données doublée d’une rupture de contrat API.

Chantier visé.

  • Base : normaliser mission.date / start_time en UTC (migration de données, avec les conversions historiques Paris→UTC), pour n’avoir plus qu’une seule sémantique.
  • Sortie : retirer ConvertDatesToTimezone et sérialiser en ISO 8601 UTC (2026-08-19T13:08:41Z), format non ambigu et parsable nativement par Flutter.
  • Entrée : accepter de l’ISO 8601 avec offset et convertir en UTC au bord (validation/FormRequest), au lieu de présumer Paris.
  • Front : traduire à l’affichage via le fuseau du device. C’est un chantier front autant que back — la bascule est une rupture du contrat API sur tous les champs datetime, à coordonner avec une version d’app, pas à livrer unilatéralement.

Contexte de découverte : relevé en branchant le segment premium.expires_at sur le profil (cf. 2.23), où le front doit armer un timer local sur l’échéance. Pour ce cas précis l’impact est cosmétique (le verdict is_premium reste calculé serveur, donc l’accès réel n’est jamais faux) — mais il a rendu le problème général visible.


Où on en est. Le socle back tient (810 tests verts, architecture services, Horizon, GlitchTip) et le front progresse vite (92 tests, error-handling unifié, modelsV2 sur enterprise/user, multi-enterprise livré). Mais l’audit du 02/07 montre l’effet de la vélocité de fin juin sans filet CI : phpstan repassé à 41 erreurs (2.9), et les chantiers récents livrés avec des trous vérifiés — une faille critique (2.17), des contrôles métier absents (2.18), un cycle expiration/relance incohérent qui perd les tokens des owners (2.19), une TVA Stripe fausse en multi-enterprise (2.20), et côté front des push owner cassés (1.14). Le pattern commun : chaque chantier est fonctionnel sur le happy path mais les chemins d’erreur/reopen/switch n’ont ni test ni revue.

Le risque principal (inchangé, aggravé) : sans CI bloquante, chaque sprint réintroduit de la dette plus vite que les audits ne la ferment.

Ordre d’attaque :

  1. Les fixes immédiats (~1 jour)2.19 (cycle expiration/relance, refund du token) ✅ fait le 02/07 ; reste 2.17 path traversal images/delete (le seul Critique), 2.18 give-feedback (manipulation de notes + force-complete arbitraire), 1.14 push owner (régression fonctionnelle visible). + quick wins : route preview web.php, « polie », throttle login (2.15).
  2. Transactions argent (2.12) — toujours le chantier structurel #1. 2.20 (Customer/tax_id multi-enterprise) ✅ réglé le 12/07 via table polymorphe stripe_customers. ~1 jour, tests de rollback inclus.
  3. CI bloquante des deux côtés (1.3 + phpstan 2.9) — c’est LA leçon de cet audit : phpstan à 0 + php artisan test côté back, flutter analyze + flutter test côté front, sur chaque push. Une demi-journée qui aurait évité la moitié des findings ci-dessus.
  4. Fiabilisation multi-enterprise front (1.15) + le reste du cycle mission back (2.19 duplication/zombie).
  5. Alignement models front/back (1.2 + 2.14) et le reste au fil de l’eau (AppStyle, legacy, UIScene 1.12 avant le SDK iOS 27 ≈ septembre).

Bus factor = 1 : aucune ligne de code ne corrige ça. Cette doc à jour est la mitigation — la maintenir à chaque chantier clos.