Dette technique au 02/07/2026
Front, Back, Infra, Transverse
Section titled “Front, Back, Infra, Transverse”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/htmlabandonné, Laravel 10 (12 dispo) — pas urgent.
1. Front-end (Flutter - Cubit)
Section titled “1. Front-end (Flutter - Cubit)”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_hourparsé 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.
1.3 CI/CD front Important
Section titled “1.3 CI/CD front Important”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çoiterroretstackTraceen 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 dansstop_overlay.dart,firebase_messaging_service.dart(~20, dont des payloads de notifs → fuite de données dans logcat) etlogin_screen.dart. À remplacer parAppLogger/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 » quithrowaient encore (getMissionDetails,getMissionWorkerIds,getMissionOwner,updateMission). Tous les cubits/écrans consommateurs adaptés (error.message ?? fallback, le lock chat viaerror.message?.contains). Plus aucunrethrowdans 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 parSessionCubit(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) ;deleteAccountn’appelle plusStringUtils.lochors 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 dansSettingsMenuSections. Le modeshowProfileInfo: 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 conventionview/:profile_screen.dart+edit_profile_screen.dart→features/profile/view/, écrans légaux →features/profile/view/legal/,update_device_token_request.dart→data/models/,payment_model.dart(0 usage) supprimé.features/auth/ne contient plus que du vrai auth.features/profileest désormais de la présentation pure (zéro cubit/repo propre, lit les cubits globaux hydratés parSessionCubit).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.
1.7 Fin de migration legacy Moyen
Section titled “1.7 Fin de migration legacy Moyen”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 3pushNameddanslogin_screen.dartetregister_form.dart- ~42 usages d’
AppStyle.poppinsXXXrestants (déprécié), dont ~20 dansregister_form.dart— 02/07 : redescendu à 30 usages sur 18 fichiers register_form.dart(659 lignes) cumule toutes les violations :_buildXxxau 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) —injectableretiré 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.
1.11 Divers Mineur
Section titled “1.11 Divers Mineur”✅ Résolu 09/07/2026 : model devenu classe pure (champs +update_profile_request.dart(model) importedio/http_parseret construit le FormData → cette logique doit vivre dans le repositorytoJson(), zéro import HTTP) et déplacé defeatures/auth/models/versdata/models/; c’estProfileRepository._toFormDataqui construit le multipart (photo + content-type).http_parserpromu dépendance directe du pubspec.Dépendances inutilisées✅ Résolu 11/06/2026 :flutter_screenutil(+ retrait duScreenUtilInitde main.dart, plus aucun.w/.h/.sp/.rdans le code),pretty_dio_loggeretinjectableretirés du pubspec. Correction de l’audit :freezedest 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-definepar cohérence) - (02/07)
owner_home_screen.dart:559: fallbackenterprise?.address ?? currentUser?.addresspour valider l’adresse avant publication de mission — hypothèse obsolète depuis le retrait de la synchro entreprise/user côté back (commit0f5a1a5) : l’adresse user ne reflète plus celle de l’entreprise (02/07) L’API INSEE back (✅ Fait 09/07/2026 :get-enterprises-by-siren, pré-remplissage création d’entreprise) n’est pas encore consommée par le front — saisie manuelle uniquement.EnterpriseCubit.lookupBySiren→getEnterprisesBySiren(sirenLookupStatus), consommé parowner_add_enterprise_screenetowner_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 :
ios/Runner/Info.plist: ajouter la cléUIApplicationSceneManifest(UIApplicationSupportsMultipleScenes = false,UISceneDelegateClassName = FlutterSceneDelegate) — absente aujourd’hui.AppDelegate.swift: sortir l’enregistrement des plugins dedidFinishLaunchingWithOptionset le déplacer dansdidInitializeImplicitFlutterEngine(protocoleFlutterImplicitEngineDelegate) →GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry). Ne plus accéder auFlutterViewControllerdansdidFinishLaunching(crash potentiel post-migration).- 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 delegateUNUserNotificationCenteractuellement posé dans l’AppDelegate)google_sign_in(^7.2.0) etsign_in_with_apple(^7.0.1) — callbackopenURLdu 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 nidart doc) vers dartdoc///: markdown stylisé concis (titre> ###, gras pour les libellés,inline-codepour les types,[refs]cliquables ; côté UI, orienté usage — ce 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_tokenpar entreprise etupdate-owner-tokenne mettait à jour qu’une entreprise ; côté front,_registerOwnerFcmTokenn’était déclenché que par leBlocListener<EnterpriseCubit>(jamais au boot → token jamais enregistré) et un guard one-shot_ownerTokenRegistered+enterpriseIdcapturé 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èsinitStatedu scaffold (_updateFCMToken,owner_main_scaffold.dart), avec rebranchement deonTokenRefresh. Plus d’enterpriseIddans la boucle, plus de guard one-shot. LeBlocListener<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 rechargeactiveEnterprise?.idau lieu dewidget.enterpriseId— consulter le dashboard d’un établissement non-actif + retry = stats d’une autre entreprise affichées.EnterpriseCubit.hydraten’est appelé qu’au bootstrap (session_cubit.dart:79) et aucun refresh ultérieur ne ré-hydrate le segment enterprises (profile_cubit.dart:192jette le segment viaidentityOnly()) : 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 parmission.enterpriseId— le feed filtré par entreprise active se pollue avec les missions des autres établissements.✅ Fait 10/07/2026 : bouton rouge plein largeur (contourEnterpriseCubit.closeEnterprisen’a aucun appelant UI : la fermeture d’entreprise (route backclose-enterprise, 422 si mission in_progress) est inatteignable pour l’utilisateur.AppColors.error+ icône corbeille, style destructif du design system) « Supprimer l’entreprise » en bas deowner_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:buildWhenne compare queid+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.
2. Back-end (Laravel - Redis - Reverb)
Section titled “2. Back-end (Laravel - Redis - Reverb)”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) etupdateStorefront()(ImageService injecté — plus aucunapp()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 fermeture —
POST enterprises/{id}/close→EnterpriseService::closeEnterprise()qui clôt proprement les missions actives viacloseEveryMissions(open → annulation raison 8, matched → release du worker, waiting_feedback → avis auto 4/5 ; unein_progressbloque tout en 422) puis archive (inactive=true, pas de delete : l’historique des missions garde son contexte établissement, vérifié par test). Le flaginactiveest filtré en opt-in via le scopeactive()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) remplaceDestroyEnterpriseTest. ⚠ 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) :
HandlesHardDeletionn’efface plus les enterprises — elles sont archivées (inactive=true) et réassignées au ghost AVANT le forceDelete (sans quoi la FKowner_id ON DELETE CASCADEles emporterait — piège attrapé par test). Les missions gardent leurenterprise_id, lesenterprise_statssurvivent. 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()+sendErrorest la convention du projet. Raisons : (a) l’enveloppe d’erreur custom ({success: false, message: première erreur}) obligerait une BaseFormRequest qui overridefailedValidation()pour recréersendError; (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
2.6 Les models stats non protégés Moyen
Section titled “2.6 Les models stats non protégés Moyen”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:243→app(FeedbackService::class)(le contournement historique, toujours là)FeedbackService→app(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/06 → ré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(signaturesRedis::pipeline,getAroundByMissionIdsappelé avec 2 params, lat/lngstring|nullpassés enfloat),StatsController(propriétés d’agrégats non déclarées),VerifyController(classeUserVerifyinconnue, type de retour invalide), jobs notification (addHours(float)— vrai bug, cf. 2.19), code mort (LookingController:46-50unreachable,EnterpriseService:258comparaison 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”adminFix :
$this->middleware('admin')ajouté surAdminStripeController(refund — la partie critique),AdminTokenOfferController, et->only('getGlobalStatsAdmin')surStatsController. Vérifié par audit croisé routesadmin/*↔ 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::workerAcceptMissionenferme la section critique (verrou + re-check + create) dansDB::transaction(fn() => { Mission::lockForUpdate()->find(id); throw si un assignment existe; create; }). LelockForUpdatesur la ligne mission sérialise les accepts concurrents — le 2ᵉ worker bloque, reprend, voit l’assignment, throwMissionAlreadyTakenException(→ 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 leFOR UPDATEaussitô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 parMissionAssignmentServiceTest(double-accept → exception). 2.12 pour ce flow reste ouvert : rendre tout le chaînage atomique exigerait de passer les side-effects demissionMatchedenafterCommit— 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::transactiondansMissionBillingServiceniStripeService. 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 ✅) puiscreateAndOpen()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:openreste 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 deMissionAssignmentService::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 parGetMissionDetailsTest(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é) :
getMissionsAdminetgetMissionFromUserAdmins(réécrits : pagination 5/10 par page +nbMissions, filtresstatus[]),updateMissionDetails(recalé sur le contrat owner, rendMissionResource) etgetAssignmentsAdmin(MissionAssignmentResource+ worker enUserResource— l’ancien retour sérialisait le User brut, token_balance compris : fuite colmatée). Au passage, l’endpointadmin/get-missions-with-idroleet sa méthode ont été supprimés (remplacés paradmin/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”✅ Résolu 09/07/2026 : la création d’entreprise étant devenue post-signup (endpoint dédiéenterprises/geocoding-testest hors groupe auth → appels Google Geocoding gratuits pour n’importe qui (coût). À protéger ou supprimer.create-enterprise), la route n’a plus besoin d’être publique — déplacée dans le groupeauth:api/banned/verified/onboarding,except()retiré du controller, tests passés enactingAs+ cas 401.test-errorpublique → à 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. Unthrottle:5,1sur le login ne coûte rien. Idem pourstripe/create-intent(frais Stripe).
2.16 Divers Mineur
Section titled “2.16 Divers Mineur”- Jobs sans
failed()handler (aucun sur les 10 jobs). Horizon montre les failed jobs dans son dashboard, mais unfailed()qui remonte vers GlitchTip surSyncMissionMatchinget les push serait cohérent avec le monitoring. Mission::getRequirementsAttribute: bug fonctionnel fixé le 11/06/2026 — l’accessor lisait la colonne CSV legacymissions.requirement_idque 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 depuismission_requirements(+ test de régression). Reste le N+1 d’origine : toujours dans$appends, 2 queries par mission sérialisée → à passer en vraie relationbelongsToMany(qui porterait aussi laprioritydu 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éimageUrlen 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✅ Résolu 12/06/2026 : colonne droppée par migration, retirée du model et de la Resource ; les skills passent parworker_profiles.skillsmorte-vivanteworker_skill_requirements.- Colonne
missions.requirement_iddé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 quetension_flag. - (06/07) Tables
experiancesetdegreesmortes de bout en bout — à enlever. Vérifié sur les 3 couches : back, elles ne sont lues que parGET get-dropdown(BaseController::getDropdown) et aucun code n’écrit jamaisworker_profiles.experience_id/degree_id(fillable + exposées dansWorkerProfilePublicResource, mais NULL pour tout le monde) ; front Flutter, les listes sont fetchées/cachées (register_cubit,skill_selection_cubitavecselectDegree/selectExperiencejamais appelés par aucun widget) mais jamais affichées ni soumises (submitSkillsn’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 appellentget-dropdownau register), puis back (route, modèlesDegree/Experiance, drop des 2 tables + colonnesexperience_id/degree_id, Resources/@property), puis types webadmin. Code mort✅ Résolu 12/06/2026 :tension_flagdansAdminUserControllerupdateUserDetailsAdminrecalé surUserService::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 middlewareadmindansAdminStripeController/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-restaurantpublique dansroutes/web.php:47, marquée « TODO: à retirer avant commit » et committée quand même — à supprimer. - (02/07)
toggleBanowner (UserAdminService.php:70) :closeEveryMissionsexé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)
loginV2lit$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 enenterprise_id. - (02/07) Dépendances : CVE low
symfony/yaml(uncomposer updatesuffit),laravelcollective/htmlabandonné (→ 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 jobFindMissionsForWorkernon seulement à la mise en ligne (putWorkerReady) mais à chaque update de position tant queready_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 synchroneWorkerController: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 :applyZoningFilterrecalcule âge/tier/haversine en PHP, ce qui duplique la logique déjà présente dansRedisGeoService(getTierForMission/getRadiusForMission) — un helper unique côtéRedisGeoServiceserait 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 seulementmission_id => existsetrecipient => integer(même pasexists:users,id). Ni controller niFeedbackService::createne 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 jamaisstatus === '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 => existssans check d’ownership ; un id appartenant à un autre owner est silencieusement ignoré (mission créée avecenterprise_id=NULLau lieu d’un 403/422). En prime,$request->all()(pasvalidated()) 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 ✅ :
ExpiredMissionCleanupJobpasse désormais parMissionBillingService::closeAndRefundMission(au lieu decloseMissiondirect) → 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é à chaqueopenMission; chaqueOverdueMissionNotificationJobporte 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 +@propertypour un compteur purement technique) ; ne rien faire (spam de push assumé).- Backoff cassé ✅ : re-dispatch en
addMinutes((int) round($h * 60))au lieu deaddHours(float)(Carbon 2 tronque la partie fractionnaire) ;BASE_DELAY_HOURSremis à 2 pour que le dispatch initial (+2h) et la suite (+3h, +4h30…) soient monotones et cohérents.- Report de date = mission zombie ✅ :
updateMissionreprogrammeExpiredMissionCleanupJobsur le nouvel horaire quanddate/start_timechange sur une missionopen— uniquement 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, voitnow < nouveau startet 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 mappingcus_xxxquitteusers.stripe_customer_id(colonne droppée) pour une table polymorphestripe_customers (stripe_customer_id, billable_type, billable_id): une identité de facturation par entité payable. Aujourd’huibillable = EnterpriseProfile(B2B, un Customer par entreprise → nom/adresse/TVA propres, plus d’écrasement croisé) ; demainbillable = Usercouvrira les achats perso worker (B2C) sans nouvelle plomberie.getOrCreateStripeCustomerlit/écrit désormais$enterprise->stripeCustomer(relationmorphOne). Backfill migration : chaquecus_idexistant 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à chaqueCustomer::updateen 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_idreste 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, maisEnterpriseService::updateautorisesirendans son allowlist → à décider séparément (retirersirende l’allowlist, ou re-sync le tax_id au changement).Rappel (contexte).
createIntentétait devenu multi-enterprise (enterprise_idrequis, commit229be65) mais le Customer restait unique par user, et letax_ideu_vat n’étant créé que sur le chemincreate, 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$updateDataque sigetCoordinatesFromAddressrenvoie un couple exploitable (isset($coords['latitude'], $coords['longitude'])). Sinon (retournullsans 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é :shouldPropagatePositionne se déclenchant que si lat/lng sontisset, 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).createnon touché : sur une entreprise neuve,?? nulln’écrase rien.Rappel (inchangé, contexte).
GeocodingHelper::getCoordinatesFromAddressretournenullsans 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::onMissionCreated — OwnerStats.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
3.2 Ajouter des docker ignore Mineur
Section titled “3.2 Ajouter des docker ignore Mineur”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.
3.4 IAC - Terraform Mineur long terme
Section titled “3.4 IAC - Terraform Mineur long terme”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. Transverse (Back + Front + Données)
Section titled “4. Transverse (Back + Front + Données)”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:s — sans 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 :
- 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. - Détection par regex, donc aveugle.
DateHelper::isDateStringmatche^\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. - Deux familles de colonnes temporelles aux sémantiques opposées.
mission.date/start_timesont de l’heure murale Paris en base (non castées, hors regex car sans partie datetime complète → elles échappent au middleware), tandis quemission_start_time/mission_end_time/created_atsont castéesdatetimeet 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_timeen UTC (migration de données, avec les conversions historiques Paris→UTC), pour n’avoir plus qu’une seule sémantique. - Sortie : retirer
ConvertDatesToTimezoneet 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.
Synthèse (02/07/2026)
Section titled “Synthèse (02/07/2026)”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 :
- 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 seulCritique), 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). - Transactions argent (2.12) — toujours le chantier structurel #1.
2.20 (Customer/tax_id multi-enterprise)✅ réglé le 12/07 via table polymorphestripe_customers. ~1 jour, tests de rollback inclus. - CI bloquante des deux côtés (1.3 + phpstan 2.9) — c’est LA leçon de cet audit : phpstan à 0 +
php artisan testcôté back,flutter analyze+flutter testcôté front, sur chaque push. Une demi-journée qui aurait évité la moitié des findings ci-dessus. - Fiabilisation multi-enterprise front (1.15) + le reste du cycle mission back (2.19 duplication/zombie).
- 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.