RAPPORT_J37.md

RAPPORT TECHNIQUE — NEXUS SPRINT J-36 → J-37

État du projet · Post-sprint QA + Wizard Critique + Auth Fix

Date : 04/04/2026 · Branche : develop · HEAD : 22fab77 (non-committé : wizard + auth) APK courant : v1.2.1-auth-fix · 28.4 Mo arm64-v8a · Tailscale 100.114.10.43 Destinataire : Lead Architecte PLCORP — contexte complet depuis le J-35


1. CONTEXTE ET PÉRIMÈTRE

Ce rapport fait suite à RAPPORT_J35.md (03/04/2026, commit 22fab77). Il couvre deux sessions de travail qui n'ont donné lieu à aucun commit intermédaire au moment de la rédaction — toutes les modifications sont dans le working tree.

Périmètre J-36 (03/04/2026) : - QA end-to-end backend (5 bugs critiques corrigés) - Audit de sécurité 100 scénarios (Gemini CR-01 à CR-04) - Corrections Flutter (JWT, filtres Drift, wizard BTP) - Pagination curseur Drift (immo_list) - 290 tests backend / 18 tests Flutter

Périmètre J-37 (04/04/2026) : - Audit wizard 50 scénarios — 12 bugs critiques identifiés et corrigés - Auth timeout Dio — résolution du blocage login Tailscale - APK rebuild v1.2.1-auth-fix déployé sur /roadmap/


2. COMMITS DU SPRINT (depuis J-35)

Les modifications J-36 et J-37 sont actuellement non commitées (working tree). Le dernier commit en production est 22fab77.

22fab77  docs(j35): rapport technique sprint J-29→J-35 + archivage session  ← dernier commit
3ea681c  chore(j35): consolidation sprint J-29→J-35 — CLAUDE.md à jour
...      (historique J-29→J-34 dans RAPPORT_J35.md)

Fichiers modifiés dans le working tree (git status) :

M backend/core/api.py
M backend/core/views.py                  ← roadmap + 5 bugs backend
M backend/immo/api.py                    ← QA immo
M backend/static/builds/*.apk            ← APK rebuild
M docker-compose.yml                     ← (hérité J-29)
M mobile/lib/core/database/app_database.dart      ← (hérité J-34)
M mobile/lib/core/database/immo_sync_service.dart ← (hérité J-34)
M mobile/lib/core/services/auth_service.dart      ← FIX J-37: Dio timeout
M mobile/lib/core/services/upload_service.dart    ← timeout 30s (J-36)
M mobile/lib/features/btp/btp_catalog_screen.dart ← (J-35)
M mobile/lib/features/btp/btp_order_screen.dart   ← (J-35)
M mobile/lib/features/immo/immo_list_screen.dart  ← pagination curseur Drift
M mobile/lib/features/map/map_screen.dart         ← (J-34)
M mobile/lib/features/wizard/steps/immo/immo_steps.dart  ← FIX J-37: 12 bugs
M mobile/lib/features/wizard/wizard_create_screen.dart   ← FIX J-37: PopScope
M mobile/test/features/wizard/auto_steps_test.dart       ← 18 tests verts

3. J-36 — QA END-TO-END BACKEND (5 BUGS CRITIQUES)

3.1 Bug #1 — Null bytes dans title/description

Fichier : backend/core/api.pycreate_property()

Django/Postgres refusait silencieusement les annonces contenant \x00 (provenant d'anciennes versions de l'app Flutter qui ne nettoyaient pas le clipboard).

# Nettoyage systématique avant INSERT
def _clean(v): return v.replace('\x00', '').replace('\\u0000', '') if isinstance(v, str) else v

Appliqué sur title, description, address, city.

3.2 Bug #2 — UUID doublon possible à la création

Fichier : backend/core/api.py

En cas de retry réseau (3G instable), uuid4() était appelé côté serveur sans vérification d'idempotence. Résultat : deux annonces identiques avec IDs différents.

Fix : Ajout d'un contrôle Property.objects.filter(title=..., owner=..., created_at__gte=now-60s) avant INSERT — rejette en 409 si doublon détecté dans les 60 dernières secondes.

3.3 Bug #3 — GPS hors bornes accepté

Fichier : backend/immo/api.py

Lat/lng non validés → Point(lng=9999, lat=-999) accepté par PostGIS, invisible sur carte.

if not (-90 <= lat <= 90 and -180 <= lng <= 180):
    raise ValidationError("Coordonnées GPS invalides")

3.4 Bug #4 — Titre accepté à 10 000 caractères

Fichier : backend/core/api.pyPropertyCreateSchema

Champ title: str sans contrainte. MaxLength ajouté à 200 caractères (cohérent avec le modèle Django max_length=200).

3.5 Bug #5 — Register 409 — message d'erreur ambigu

Fichier : backend/core/api.pyregister()

En cas de numéro déjà utilisé, le message retourné était "User already exists" (anglais, exposait la structure interne). Remplacé par "Ce numéro est déjà enregistré.".


4. J-36 — CORRECTIONS FLUTTER

4.1 JWT Expiry Fix

Fichier : mobile/lib/core/services/auth_service.dart

_isJwtExpired() avait une marge de sécurité de 0s — le token était considéré valide jusqu'à la dernière milliseconde. Sur réseau lent, les requêtes échouaient avec 401 alors que le token semblait valide côté client.

Fix : Marge de 30s : expiry.subtract(const Duration(seconds: 30)).

4.2 Filtres Drift WHERE manquants

Fichier : mobile/lib/features/immo/immo_list_screen.dart

Les filtres catégorie/searchQuery de l'écran immo_list construisaient la requête Drift mais n'ajoutaient pas les clauses WHERE correspondantes — toutes les annonces s'affichaient quel que soit le filtre.

Fix : Ajout des conditions & (entries.category.equals(category)) et & (entries.title.contains(searchQuery) | entries.description.contains(searchQuery)).

4.3 Pagination curseur Drift

Fichier : mobile/lib/core/database/app_database.dart + immo_list_screen.dart

Remplacement offset-based → cursor-based pour la pagination immo_list.

// Avant (offset)
getPropertiesPage(limit: 20, offset: _offset)

// Après (cursor par createdAt DESC)
getPropertiesPageCursor(limit: 20, afterCreatedAt: _cursor)

Pourquoi : Avec 1059+ biens en local et une sync delta active, l'offset causait des doublons/gaps à chaque insertion serveur. Le cursor par createdAt DESC est stable.

4.4 BTP Wizard Steps branchées

Fichier : mobile/lib/features/wizard/wizard_create_screen.dart

Les imports btp_steps.dart et les case WizardStep.btpPackaging/btpDelivery/btpPrice dans le switch étaient absents — les étapes BTP du wizard affichaient un widget vide.

Fix : Import ajouté + 3 cases branchés sur BTPPackagingStep, BTPDeliveryStep, BTPPriceStep.

4.5 URLs hardcodées supprimées (5 fichiers)

Toutes les occurrences de "http://192.168.1.111:8000" ou "http://100.114.10.43:8000" remplacées par String.fromEnvironment('API_BASE_URL', defaultValue: '...') : - auth_service.dart - upload_service.dart - immo_sync_service.dart - btp_order_screen.dart - map_screen.dart


5. J-36 — AUDIT SÉCURITÉ 100 SCÉNARIOS (GEMINI)

Audit complet disponible dans SECURITY_REPORT_100_SCENARIOS.md. Résumé des actions prises :

CR Vulnérabilité Statut Action
CR-01 PAT GitHub exposé dans historique git ⚠️ OUVERT Action manuelle requise : github.com/settings/tokens
CR-02 Ports Docker exposés publiquement (5432, 6379, 9000) ✅ CORRIGÉ 127.0.0.1:PORT:PORT dans docker-compose.yml
CR-03 MinIO credential par défaut minioadmin/minioadmin ✅ CORRIGÉ Env vars MINIO_ROOT_USER/PASSWORD externalisées
CR-04 SECRET_KEY Django hardcodée ✅ CORRIGÉ os.environ.get('DJANGO_SECRET_KEY', fallback_dev_only)

Protections confirmées en place (non-régressions) : - Brute-force login : @rate_limit 10 req/5min - IDOR : vérification owner sur tous endpoints IMMO + BTP - Mass Assignment : schémas Ninja explicites, is_verified/role non exposés - Magic bytes upload : Image.open() + image.load() → ré-encode JPEG - JWT None algorithm : algorithms=["HS256"] explicite (PyJWT 2.x) - Dump DB : limit = min(limit, 1000) sur tous les endpoints list

Sécurité #91 : phone_number remplacé par user#ID dans tous les logs Django. Sécurité #66 : SECURE_CONTENT_TYPE_NOSNIFF = True ajouté. Blocs HSTS/CSP documentés pour prod dans settings.py.


6. J-37 — AUDIT WIZARD 50 SCÉNARIOS + CORRECTIONS CRITIQUES

Audit complet disponible dans AUDIT_SECURITY_REPORT.md.

6.1 Bug #1+#50 — Photos définitivement perdues (CRITIQUE)

Impact : 100% des annonces créées depuis le wizard n'avaient aucune photo liée.

Cause racine : MediaUploadStep stockait les URLs uploadées dans metadata['photo_urls'], mais _saveProperty() itérait state.photos (liste de chemins locaux, toujours vide). Les photos existaient sur MinIO mais aucun enregistrement PropertyImages n'était créé.

// Avant — CASSÉ
for (final path in state.photos) { ... }  // state.photos = [] toujours

// Après — CORRIGÉ
final photoUrls = (state.metadata['photo_urls'] as List? ?? []).cast<String>();
for (final url in photoUrls) {
  await _db.addPropertyPhoto(PropertyImagesCompanion(
    serverUrl: Value(url),
    isSynced: const Value(true),  // Déjà sur le serveur
  ));
}
// state.photos supporté en parallèle pour mode offline-first futur

6.2 Bug #2 — _saveProperty() fire-and-forget (CRITIQUE)

Impact : En cas d'erreur Drift (espace disque, contrainte), l'utilisateur voyait "Succès" mais rien n'était sauvegardé.

Fix : nextStep() rendu async avec état isSaving + try/catch + saveError.

// wizard_controller.dart
Future<void> nextStep() async {
  if (state.isSaving) return; // Anti double-tap
  if (state.steps[state.currentStepIndex + 1] == WizardStep.success) {
    state = state.copyWith(isSaving: true, clearSaveError: true);
    try {
      await _saveProperty();
      state = state.copyWith(isSaving: false, currentStepIndex: ...);
    } catch (e) {
      state = state.copyWith(isSaving: false,
        saveError: "Erreur de sauvegarde. Vérifiez votre espace disque.");
    }
    return;
  }
  ...
}

wizard_create_screen.dart affiche un CircularProgressIndicator dans le bouton "Suivant" pendant isSaving, et un SnackBar rouge avec action "Réessayer" en cas d'erreur.

6.3 Bug #5 — Duplication via bouton retour Android depuis Success

Fix : PopScope(canPop: false) remplace le Scaffold nu. Si des données sont en cours de saisie → dialogue de confirmation avant abandon. Depuis Success → pas de retour possible.

6.4 Bug #7 — Champs vides au retour (BasicsStep, ImmoLegalStep, ImmoCharacteristics)

Cause : initialValue sur TextField ne se met pas à jour si le widget est reconstruit.

Fix : Tous les TextField sensibles convertis en ConsumerStatefulWidget avec TextEditingController initialisé depuis metadata dans initState().

6.5 Bug #11 — Surface m² vide au retour

Même cause que #7, même fix — _surfaceCtrl = TextEditingController(text: meta['surface_m2']?.toString()).

6.6 Bug #15 — transaction_type absent pour AUTO et BTP

Sans transaction_type, toutes les annonces AUTO/BTP étaient créées avec 'location' par défaut Drift — incorrect pour la vente de matériaux BTP.

// wizard_controller.dart — _saveProperty()
String defaultTxType = 'location';
if (state.sector == 'btp') defaultTxType = 'vente';

BasicsStep propose désormais les chips de transaction par secteur (IMMO: location/vente/colocation, AUTO: location/avec chauffeur, BTP: pas de chips — vente forcée).

6.7 Bug #17 — Upload silencieux sans feedback

Catch vide remplacé par SnackBar rouge : "Échec de l'upload. Vérifiez votre connexion.". ScaffoldMessenger capturé avant le gap async pour éviter le crash post-unmount.

6.8 Bug #20 — Prix FCFA avec espaces → 0 FCFA

"25 000 000"int.tryParse = null → 0 FCFA sauvegardé.

Fix : Nettoyage avant parse : v.replaceAll(RegExp(r'[\s,.]'), ''). Helper text ajouté : "Saisir sans espaces ni virgules".

6.9 Bug #24 — Photos illimitées

Limite _kMaxPhotos = 8 ajoutée. Le bouton "Ajouter" se masque automatiquement, le compteur ${photos.length}/$_kMaxPhotos vire à l'orange à la limite.

6.10 Bug #35 — Crash ScaffoldMessenger après unmount

ScaffoldMessenger.of(context) appelé après un awaitcontext possiblement unmounted. Fix : capture du messenger avant l'await.

6.11 Bug #37 — Aucun bouton "Annuler" avec confirmation

PopScope avec dialogue AlertDialog("Abandonner ? / Vos données non publiées seront perdues.") Sur l'étape sector ou si aucune donnée → retour immédiat sans confirmation.


7. J-37 — AUTH TIMEOUT (BLOCAGE LOGIN TAILSCALE)

Symptôme

Écran de chargement infini lors de la connexion sur TECNO CAMON 19, même avec Tailscale actif. Le backend répondait correctement en direct (curl en 50ms).

Cause racine

Dio(BaseOptions(baseUrl: _baseUrl)) sans aucun connectTimeout ni receiveTimeout. Comportement par défaut Dio : attente indéfinie. En cas de relay Tailscale lent (relay Paris, latence variable), la requête pendait sans jamais échouer.

Flux d'état :

login() → state = AuthLoading → main.dart rebuild → spinner plein écran
→ Dio attend ∞ → jamais de AuthAuthenticated ni AuthError
→ UI bloquée

Fix

// mobile/lib/core/services/auth_service.dart
_dio = dio ?? Dio(BaseOptions(
  baseUrl: _baseUrl,
  connectTimeout: const Duration(seconds: 10),
  receiveTimeout: const Duration(seconds: 15),
))

Message d'erreur différencié

// mobile/lib/features/auth/screens/login_screen.dart
final isNetworkError = authState.message.toLowerCase().contains('timeout') ||
    authState.message.toLowerCase().contains('connection') ||
    authState.message.toLowerCase().contains('socketexception');
_errorMessage = isNetworkError
    ? 'Serveur inaccessible. Vérifiez Tailscale ou votre Wi-Fi.'
    : 'Numéro ou mot de passe incorrect.';

8. ÉTAT DES TESTS

Backend (pytest)

290 tests / 290 verts
0 échecs · 0 erreurs · 0 warnings bloquants
PydanticDeprecatedSince20 : warnings non-bloquants (vient de django-ninja)

Flutter

18 tests / 18 verts
- test/core/widgets/      : PLChoiceChip, PLCounterField, PLStepper (8 tests)
- test/features/wizard/   : WizardController, AutoSteps (10 tests)
- ClientException OSM tiles : attendu en test headless, non-bloquant

Analyze Flutter

flutter analyze : 0 erreurs · 0 warnings · ~10 infos withOpacity (connues, non-bloquantes)

9. ARCHITECTURE — POINTS NOTABLES

9.1 Wizard State Machine (Flutter)

WizardStep enum → setSector() construit la liste steps dynamiquement
sector → category → basics → location → [steps secteur] → photos → success

IMMO : + immoCharacteristics + immoEquipment + immoLegal
AUTO : + autoSpecs + autoOptions + autoPricing
BTP  : + btpPackaging + btpDelivery + btpPrice

_saveProperty() est la seule méthode qui écrit en base Drift. Elle est appelée uniquement lors de la transition vers WizardStep.success (guard nextStep()).

9.2 Photos — Double pipeline intentionnel

_saveProperty() gère deux sources de photos en parallèle : - metadata['photo_urls'] → URLs MinIO (upload déjà fait via MediaUploadStep) → isSynced: true - state.photos → chemins locaux (mode offline-first prévu) → isSynced: false

L'immo_sync_service.dart détecte serverPhotoId != null (Drift v4) pour skiper les ré-uploads à l'édition.

9.3 Auth — Flux JWT

Login → /api/auth/login → access_token (15min) + refresh_token (7j)
→ FlutterSecureStorage
→ _isJwtExpired() vérifie exp - 30s côté client
→ Dio interceptor (à implémenter en J-38) pour refresh automatique

Note architecte : Le refresh automatique (intercepteur Dio qui rappelle /auth/refresh sur 401) n'est pas encore implémenté. Actuellement, un token expiré force une reconnexion manuelle. Priorité J-38 si la session de 15min se révèle trop courte terrain.

9.4 Pagination Drift — Cursor-based

// Stable vs offset lors de syncs delta concurrentes
SELECT * FROM property_entries
WHERE created_at < :cursor          -- cursor = createdAt du dernier item
ORDER BY created_at DESC
LIMIT 20

Reset du cursor sur tout changement de filtre (catégorie, searchQuery, pull-to-refresh).


10. DETTES TECHNIQUES CONNUES

ID Description Impact Priorité
DETTE-01 Refresh JWT automatique absent (intercepteur Dio) Session expire silencieusement après 15min J-38
DETTE-02 state.photos (chemins locaux) jamais utilisé en prod Code mort (offline-first prévu J-51 PowerSync) J-51
DETTE-03 RegisterScreen dans login_screen.dart (même fichier) Violation SRP, à déplacer dans register_screen.dart Prochain sprint
DETTE-04 CR-01 PAT GitHub — révocation manuelle requise Risque sécurité si repo piraté URGENT
DETTE-05 Wizard BTP crée PropertyEntries, pas Material Commandes BTP depuis wizard incompatibles avec FSM J-38
DETTE-06 ImmoEquipmentStepColumn sans SingleChildScrollView Overflow sur petits écrans (clavier) J-38
DETTE-07 Photos pas de cache local (Image.network sans cache) Photos disparaissent en mode avion J-51 PowerSync

11. ROADMAP RESTANTE

J-37 (suite) : Tests de charge 1000 biens PostGIS + benchmark Redis k6
               Objectif P95 < 200ms sur /api/immo/listings (avec ST_DWithin)

J-38 : ★ JALON MVP IMMO ★
       - Validation terrain Gagnoa (agents + clients réels)
       - Tag v1.0.0-mvp + changelog public
       - Refresh JWT automatique (DETTE-01)
       - Wizard BTP → crée Material et non PropertyEntry (DETTE-05)
       - KYC Wave CI Business (délai 2-3 semaines — à initier MAINTENANT)

J-51 : Module AUTO + WhatsApp Business API + Wallet PLCORP
J-86 : Migration PowerSync (remplace delta sync polling Drift)

12. ACCÈS ET ENVIRONNEMENT

# Lancer l'environnement
docker-compose up -d
# Vérifier : backend healthy
curl http://192.168.1.111:8000/api/auth/health
# → {"status": "healthy", "service": "nexus-backend"}

# Tests backend
docker exec nexus_backend sh -c "cd /app && pytest -q"
# → 290 passed

# Tests Flutter
cd mobile && flutter test
# → 18 passed

# Rebuild APK (exemple Tailscale)
cd mobile && flutter build apk --split-per-abi --release \
  --dart-define=API_BASE_URL=http://100.114.10.43:8000/api \
  --dart-define=BUILD_DATE=$(date +%d/%m/%Y) \
  --dart-define=GIT_COMMIT=$(git rev-parse --short HEAD) \
  --dart-define=GIT_BRANCH=$(git rev-parse --abbrev-ref HEAD) \
  --dart-define=BUILD_ENV=tailscale-dev

# Comptes de test (TECNO CAMON 19)
# Admin  : +2250700000001 / Admin1234!
# Agent  : +2250700000002 / Agent1234!
# Client : +2250700011200 / Client1234

# Tailscale
# PC (backend) : 100.114.10.43
# TECNO        : 100.76.75.34  (à connecter depuis l'app Tailscale)
# APK disponible à télécharger : http://192.168.1.111:8000/roadmap/

13. ACTIONS IMMÉDIATES REQUISES

  1. CR-01 — URGENT : Révoquer le PAT GitHub exposé → https://github.com/settings/tokens
  2. KYC Wave CI : Initier le dossier KYC Business Wave → https://wave.com/ci/business (délai 2-3 semaines — bloque le paiement réel pour J-38)
  3. Commit J-36+J-37 : Les modifications du working tree doivent être commitées avant J-38 pour conserver l'historique propre

Rapport généré le 04/04/2026 — Prince Lahide (Lead Developer) via Claude Sonnet 4.6 Nexus by PLCORP — Gagnoa 2026