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.py — create_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.py — PropertyCreateSchema
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.py — register()
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 await → context 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 | ImmoEquipmentStep — Column 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
- CR-01 — URGENT : Révoquer le PAT GitHub exposé → https://github.com/settings/tokens
- 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)
- 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