← Tous les manuels · Portail
↗ Ouvrir l'app

MANUAL — moa-properties-crm

Manuel d'instructions de cette app. À LIRE avant toute intervention, et à METTRE À JOUR après chaque modification (ajouter une ligne au CHANGELOG en bas).
Dernière génération/MAJ : 2026-09-04 · Statut : en ligne (v1)

1. Identité

  • - Rôle (à quoi sert l'app, pour qui) : TODO — décrire à quoi sert l'app et pour qui
  • - URL publique : https://crm-properties.panelbay.com
  • - Entité : MP
  • 2. Exécution / infra

  • - Dossier : /root/workspace/apps/moa-properties-crm
  • - Stack : Node.js, Python, HTML/statique
  • - Service systemd : moa-properties-crm.service (systemctl restart moa-properties-crm.service)
  • - Port : 8333
  • - Reverse proxy (nginx) : crm-properties.panelbay.com
  • - Redémarrer : systemctl restart moa-properties-crm.service
  • 3. Structure & fichiers clés

  • - backend/server.js
  • - backend/db.js
  • - frontend/index.html
  • - backend/package.json
  • - backend/ai_draft_agent.py (agent IA de rédaction des réponses WhatsApp, Phase 1) + backend/run_ai_draft_agent.sh (wrapper cron */3, log /root/aios/logs/mp-ai-draft-agent.log)
  • - backend/relance_agent.py (agent IA de RELANCE des propriétaires silencieux, Phase 1b) + backend/run_relance_agent.sh (wrapper cron 0 10 * * *, log /root/aios/logs/mp-relance-agent.log)
  • - backend/projection_gate_agent.py (agent "porte de projection", garde-fou Option B : NE PARLE PAS au prospect, repère les leads qualifiés au stade projection, calcule l'estimation via le skill moa-properties-projection/estimate.py quand la zone est captée en AirDNA, génère le teaser PDF 1 page (generate_projection_doc_1page.py, cf. CHANGELOG 2026-09-02) et NOTIFIE Matt via lib/notify.py — prêtes à valider (avec chemin du PDF) vs données manquantes ; dédup via data/projection_gate_state.json) + backend/run_projection_gate.sh (wrapper cron 15 * * * *, log /root/aios/logs/mp-projection-gate.log)
  • - frontend/js/app.js (renderApprovals = vue « À approuver ») · frontend/js/api.js (aiDraft*)
  • - backend/confirm_rdv_agent.py (juge STRICT si un RDV réservé est réellement confirmé par le prospect, cf. CHANGELOG 2026-09-03 (4)) + run_confirm_rdv_agent.sh (cron */10 * * * *)
  • - backend/manual_reply.py (envoi manuel WhatsApp par Claude, filet de style + --lead-id active le suivi "document vu", cf. CHANGELOG 2026-09-03 (5) et (6))
  • - backend/backfill_next_step.py (one-off, comble next_action/next_action_date du stock existant) + backend/next_step_watchdog.py (garde-fou quotidien, purement mécanique) + run_next_step_watchdog.sh (cron 30 8 * * *, log /root/aios/logs/mp-next-step-watchdog.log), cf. CHANGELOG 2026-09-03 (7)
  • - backend/pause_review_agent.py (rédige le recap IA de la file "Leads en pause") + run_pause_review_agent.sh (cron */10 * * * *, log /root/aios/logs/mp-pause-review-agent.log) · frontend/js/app.js (renderPauseReviews/pauseReviewCard = nouvel onglet « En pause »), cf. CHANGELOG 2026-09-03 (8)
  • - backend/decision_agent.py (traduit en action la décision de Matt tapée sur une carte "en pause" : brouillon WhatsApp dans ai_drafts et/ou next_action) + run_decision_agent.sh (cron */3 * * * *, log /root/aios/logs/mp-decision-agent.log) · frontend/js/app.js (pauseReviewCard = bloc "Ta décision" ; draftCard = badge "📝 Ta décision" sur les brouillons kind='decision'), cf. CHANGELOG 2026-09-04
  • - backend/relance_sequence.py (séquence de relance SCRIPTÉE 7 jours pour les non-répondeurs, contenu figé, PAS d'IA ; état data/relance_sequence_state.json ; cmds init/run/status) + run_relance_sequence.sh (cron 30 11 * * *, log /root/aios/logs/mp-relance-sequence.log), cf. CHANGELOG 2026-09-10
  • 4. Données

  • - Base / stockage : - backend/data/moa-properties-crm.db
  • - Modèle / tables : users (équipe, rôles admin/agent) · leads (les PROPRIÉTAIRES à signer en gestion, pipeline sans_contact→…→gagne/perdu) · properties (biens gérés, synchronisés depuis le Cerveau MOA, airbnb_id clé de sync) · activities (timeline par lead) · lead_properties (liens lead↔bien) · wa_sessions + wa_messages (WhatsApp) · meetings (RDV commerciaux rattachés à un lead, cf. CHANGELOG 2026-08-12 ; prospect_confirmed pending/yes/no cf. CHANGELOG 2026-09-03 (4)) · ai_drafts (file d'approbation : brouillons de réponses WhatsApp rédigés par l'IA, status pending/sent/rejected/shadow/skipped, un seul pending par numéro via index unique ; skipped = décision "rien à répondre" mémorisée par message déclencheur, cf. CHANGELOG 2026-09-06) + ai_feedback (boucle d'apprentissage : consignes humaines relues par l'IA avant de rédiger), cf. CHANGELOG 2026-08-13 « système d'autorisation » · projection_docs (suivi "document vu", étape 3 du process qualif : status pending/relance_sent/seen/stale/closed, cf. CHANGELOG 2026-09-03 (6)) · pause_reviews (file "Leads en pause", cf. ligne suivante) · lead_decisions (décision de Matt en langage naturel sur un lead en pause + résultat de son traitement par decision_agent.py, status pending/done/error, cf. CHANGELOG 2026-09-04).
  • 5. API / endpoints

  • - DELETE /api/leads/:lid
  • - DELETE /api/leads/:lid/properties/:pid
  • - DELETE /api/properties/:pid
  • - GET /api/dashboard
  • - GET /api/health
  • - GET /api/leads
  • - GET /api/leads/:lid
  • - GET /api/leads/:lid/matches
  • - GET /api/me
  • - GET /api/meta
  • - GET /api/properties
  • - GET /api/users
  • - PATCH /api/leads/:lid
  • - PATCH /api/properties/:pid
  • - POST /api/ingest/lead
  • - POST /api/leads
  • - POST /api/leads/:lid/activities
  • - POST /api/leads/:lid/properties
  • - POST /api/login
  • - GET /api/password-setup/:token (public, valide un lien d'invitation, renvoie name/email)
  • - POST /api/password-setup (public, {token,password} → définit le mot de passe, consomme le jeton, connecte)
  • - POST /api/properties
  • - POST /api/sync/properties
  • - GET /api/whatsapp/status · POST /api/whatsapp/connect (QR) · POST /api/whatsapp/logout
  • - POST /api/whatsapp/webhook/:instance (public, gardé par ?token=, réception Evolution)
  • - GET /api/whatsapp/inbox · GET /api/whatsapp/thread?number= · POST /api/whatsapp/thread/create-lead
  • - Système d'autorisation IA (file d'approbation des réponses WhatsApp) :
  • - Auto-envoi en phase de qualification (Phase 2, mode ombre/actif) :
  • - Confirmation RÉELLE des RDV (meetings.prospect_confirmed) : GET /api/ai/pending-rdv-confirmations (agent) liste les RDV scheduled+pending avec du nouveau depuis la réservation ; POST /api/ai/rdv-confirmation (agent) dépose le verdict yes/no/pending. Cf. CHANGELOG 2026-09-03 (4).
  • - Suivi "document vu" (étape 3 qualif, projection_docs) : POST /api/ai/projection-doc-sent (agent, appelé par manual_reply.py --pdf --lead-id) enregistre l'envoi d'un document et active le suivi. Pas d'endpoint GET dédié : un cron SERVEUR interne (runDocFollowups, setInterval 15 min) ferme la boucle tout seul, sans validation Matt pour cette étape précise (seul l'envoi initial du document reste gaté) : réponse entrante détectée depuis l'envoi → seen + notif "à vous de proposer la vidéollamada" ; 24h de silence → relance auto envoyée (relance_sent) ; 48h de silence supplémentaires → stale + notif "main humaine requise" ; RDV pris/lead clos/ia_off entretemps → closed (silencieux). Cf. CHANGELOG 2026-09-03 (6).
  • - Next step + date garanti sur tout lead actif (leads.next_action/next_action_date) : plusieurs points du serveur les tiennent à jour en continu (marqueur [auto] = note système, jamais écrasé sur un texte tapé à la main) : book-rdv (date = RDV réservé) · waSendReply (tout envoi WhatsApp sur un lead en qualif fait rouler la date de +2j, cadence par défaut) · runDocFollowups (vu/relancé/stale) · projection-doc-sent (date = +1j à l'envoi). Backfill ponctuel + garde-fou quotidien : cf. CHANGELOG 2026-09-03 (7).
  • - File "Leads en pause" (pause_reviews) : dès que l'IA s'arrête sur un lead (flagPauseForReview, 3 sources : handover_interest — l'IA juge le proprio chaud, manual_takeover — reprise manuelle détectée sur WhatsApp, manual_tag — tag ia_off posé à la main dans l'app), une revue est créée (idempotent, pas de doublon tant que la précédente n'est pas écartée). GET/POST /api/ai/pending-pause-summaries + /api/ai/pause-summary (agent, pause_review_agent.py) rédigent un recap FR court. GET /api/pause-reviews (+ /count, UI) sert le recap + les 5 derniers messages ; POST /api/pause-reviews/:id/dismiss écarte la carte une fois la décision prise SUR LA FICHE du lead. Cf. CHANGELOG 2026-09-03 (8).
  • - Décision de Matt sur un lead en pause (lead_decisions) : Matt tape sa décision en langage naturel directement sur la carte "en pause" (ou sur la fiche) — POST /api/leads/:lid/decisions (UI, auth+requireWa, {instruction, pause_review_id?}) l'enregistre, journalise une activité, et écarte la revue pause_reviews liée si fournie. GET /api/ai/pending-decisions (agent) sert la fiche + les 20 derniers messages à decision_agent.py (cron */3 * * * *), qui traduit la décision en action : un message à envoyer part dans ai_drafts via POST /api/ai/decision-drafts (agent, kind='decision', TOUJOURS pending, remplace un éventuel brouillon en attente sur ce numéro) — jamais d'envoi WhatsApp automatique même sur décision explicite, ça atterrit dans la file "À approuver" existante, un clic pour envoyer ; une action interne (relance à programmer) pose next_action/next_action_date directement. POST /api/ai/decisions/:did/result (agent) clôture la décision (status done/error + result_summary), journalise sur la fiche et notifie Matt (bell/push AIOS, jamais le chat). Cf. CHANGELOG 2026-09-04.
  • 6. Intégrations & secrets

  • - Intégrations (n8n, Google Sheets, Airtable, webhooks, autres apps) : sync "biens gérés" depuis le Cerveau MOA (POST /api/sync/properties, gardé par X-Sync-Token/X-Ingest-Token) ; ingestion de leads externes (pub Meta, formulaires) via POST /api/ingest/lead (gardé par X-Ingest-Token).
  • - Secrets / clés (indiquer OÙ elles sont, JAMAIS leur valeur) : backend/.env porte INGEST_TOKEN, JWT_SECRET (signature des tokens JWT, lu par server.js), CORS_ORIGINS (liste d'origines autorisées), et le bloc WhatsApp : EVOLUTION_API_URL, EVOLUTION_API_KEY (clé Evolution partagée avec les autres CRM), WA_ALLOWED_EMAILS, WA_PUBLIC_BASE, WA_WEBHOOK_SECRET.
  • - WhatsApp (Evolution API) : module d'appairage par QR + réception (inbox), branché sur le serveur Evolution auto-hébergé (127.0.0.1:8080, partagé avec moa-crm). Une instance Evolution PAR utilisateur, nom mpcrm_<userId> (préfixe distinct de moacrm_ pour ne pas collisionner). Réception SEULE (aucun envoi depuis le CRM). Le webhook Evolution pointe en loopback (WA_PUBLIC_BASE=http://127.0.0.1:8333), gardé par WA_WEBHOOK_SECRET. Tables wa_sessions (1 session appairée/user) et wa_messages (inbox). Accès réservé aux admin + emails de WA_ALLOWED_EMAILS (défaut : Marlon). Module backend/wa_evolution.js.
  • 7. Pièges connus / à savoir

  • - Aucun accusé de réception/lecture sur les sortants WhatsApp. Le CRM sait qu'un message est parti (il a un wa_message_id) mais pas s'il a été livré ou lu par le destinataire (aucune capture des ACK Evolution). Ne jamais présenter un RDV/message comme "confirmé" sur cette seule base, cf. CHANGELOG 2026-09-03 (3).
  • - Rôles : 2 comptes admin (Matt, Seb) + 1 agent (Marlon). Les suppressions (DELETE /api/leads/:id, DELETE /api/properties/:pid) et la réassignation d'un propriétaire (changer assignee_id) sont réservées aux admin (middleware requireAdmin côté serveur ; le front masque aussi ces contrôles pour les agents). Ne pas retirer ces gardes.
  • - JWT_SECRET : doit être défini dans backend/.env. Un fallback non sécurisé existe pour ne pas crasher, mais un WARN est loggué au démarrage s'il manque. Rotation gérée par l'orchestrateur (voir Audit-apps/_secret_rotation_moa-properties-crm.tsv).
  • - CSP / CORS : Helmet impose une CSP (fonts Google autorisées, styles inline autorisés car le DOM est construit via el()). CORS restreint aux origines de CORS_ORIGINS. Si on ajoute un CDN/script externe, penser à élargir la CSP.
  • - Pagination : GET /api/leads et /api/properties acceptent limit/offset (défaut 500, max 2000). Le shaping des listes est groupé (pas de N+1).
  • - Matching biens (/api/leads/:id/matches) : score sur zone/unit_type contre les biens status='actif' (les biens gérés). L'ancien scoring budget/prix (hérité du CRM de vente) a été retiré.
  • - Upload de fichiers (pièces jointes WhatsApp) = nginx doit autoriser >1 Mo. L'endpoint reply-media accepte 16 Mo (express.raw), mais nginx en frontal plafonne par défaut le corps à 1 Mo et renvoie 413 avant d'atteindre le CRM (symptôme : « erreur » à l'envoi d'un PDF). Les deux vhosts portent désormais client_max_body_size 20m; dans leur location / (/etc/nginx/sites-enabled/crm-properties.panelbay.com.conf et ...bouthors-m.tenga.run.conf). Ne pas retirer. Si un jour on veut des fichiers plus lourds, relever À LA FOIS cette valeur ET la limite express.raw de reply-media (server.js) ET le garde-fou front (16 Mo dans app.js).
  • - WhatsApp / webhook Evolution = URL PUBLIQUE obligatoire (PAS de loopback). Evolution tourne en conteneur Docker (evolution-api, publié 127.0.0.1:8080->8080). Depuis le conteneur, 127.0.0.1:8333 désigne le conteneur, pas l'hôte : un WA_PUBLIC_BASE=http://127.0.0.1:8333 fait que le webhook n'atteint JAMAIS le CRM et aucune conversation n'arrive (wa_messages reste vide alors que la session est open). WA_PUBLIC_BASE DOIT être l'URL publique https://crm-properties.panelbay.com (routée par nginx), exactement comme moa-crm. Après un changement de WA_PUBLIC_BASE : redémarrer le service (les futurs re-appairages QR poseront la bonne URL) ET re-poser le webhook des instances déjà connectées via POST {EVOLUTION_API_URL}/webhook/set/{instance} (sinon il faut rescanner le QR). Diagnostic : GET /webhook/find/{instance} doit montrer l'URL publique + events:["MESSAGES_UPSERT"]. Le webhook ne rejoue PAS les anciens messages : l'inbox se remplit à partir des nouveaux entrants.
  • 8. CHANGELOG

  • - 2026-09-04 (2) — Deux correctifs immédiats après retour de Matt sur la fonctionnalité "décision" ci-dessous. (1) Cache-bust front oublié : Matt ne voyait pas le nouveau bloc "Ta décision" malgré le restart backend — index.html référençait toujours app.js/api.js/styles.css avec les anciens ?v=... (pratique constante de cette app, cf. entrées passées type ?v=20260826waconv), donc les navigateurs déjà ouverts gardaient leur copie mise en cache. Bumpé les 3 en ?v=20260904decision. Vérifié après restart : index.html sert bien les nouveaux ?v=, app.js contient appr-decision/submitDecision. **Leçon retenue : toute modif de frontend/js/*.js ou styles.css doit systématiquement s'accompagner d'un bump du cache-bust dans index.html, sans quoi le restart backend seul ne suffit pas à faire apparaître le changement chez l'utilisateur. (2) Typo nom admin** : "Mathieu Bouthors" (1 t) → "Matthieu Bouthors" (2 t), en base (UPDATE users) ET dans le seed (db.js, pour toute réinstallation future) ; commentaire manual_reply.py aligné. Vérifié : SELECT name renvoie la bonne orthographe.
  • - 2026-09-04 — "Leads en pause" devient actionnable : Matt tape sa décision, l'IA l'exécute (brouillon À approuver et/ou action interne). Demande Matt (retour sur l'onglet livré le 03/09) : "j'aime bien le bouton écarter quand je gère à la main, mais 'voir toute la conversation et décider' ne fait qu'ouvrir la fiche, pas de moyen de donner ma décision et que ça envoie la tâche à l'AIOS." DB (db.js) : nouvelle table lead_decisions (lead_id, pause_review_id, instruction, status pending/done/error, result_summary, created_by, resolved_at). BACKEND (server.js) : POST /api/leads/:lid/decisions (UI) enregistre l'instruction de Matt, journalise une activité "Décision donnée", écarte la revue pause_reviews liée immédiatement (pas d'attente du traitement pour que la carte disparaisse) ; GET /api/ai/pending-decisions (agent) sert fiche+20 derniers messages ; POST /api/ai/decision-drafts (agent) dépose un brouillon WhatsApp TOUJOURS pending dans ai_drafts (kind='decision', remplace un brouillon pending existant sur ce numéro — la décision explicite de Matt prime), délibérément SANS passer par la logique d'auto-envoi de /api/ai/drafts (qui reste réservée à la qualification IA off/shadow/live) : jamais d'envoi WhatsApp automatique même sur décision explicite, cf. règle "jamais de comm externe sans confirmation" ; POST /api/ai/decisions/:did/result (agent) clôture (result_summary, next_action/next_action_date optionnels), journalise sur la fiche et notifie Matt via notifyOperator (bell/push, pas le chat). NOUVEL AGENT decision_agent.py (+ run_decision_agent.sh, cron */3 * * * *, log /root/aios/logs/mp-decision-agent.log) : demande au CLI claude de traduire la décision en {send_message, message, next_action, next_action_date, result_summary}, avec la date du jour injectée dans le prompt pour résoudre les échéances relatives ("mardi", "cette semaine") ; mêmes règles de style que ai_draft_agent.py/manual_reply.py (espagnol rioplatense, jamais de ¿/¡ ouvrant, message calqué sur ce que Matt a demandé, rien d'inventé en plus). Périmètre volontairement restreint en v1 : pas de création de RDV structuré (meetings, trop de champs obligatoires pour une instruction courte) — si un créneau doit être verrouillé, ça reste un message proposé (approuvé par Matt) plutôt qu'une réservation auto ; à étendre plus tard si besoin (l'agent pourrait appeler /api/ai/book-rdv, déjà simple). FRONT (app.js) : pauseReviewCard gagne un bloc "Ta décision" (textarea + bouton, visible seulement si un lead est rattaché) qui retire la carte au clic (API.submitDecision) ; draftCard affiche un badge "📝 Ta décision" + libellé dédié sur les brouillons kind='decision'. api.js : submitDecision(leadId, instruction, pauseReviewId). CSS .appr-decision (styles.css). Bug préexistant corrigé au passage (repéré en testant lead_decisions, qui a la même FK que pause_reviews) : DELETE /api/leads/:lid ne nettoyait pas pause_reviews avant de supprimer la fiche (FK constraint failed dès qu'un lead avait eu une revue "en pause") — ajouté au même endroit que lead_decisions. Testé en live de bout en bout sur un lead de test dédié (créé puis supprimé, aucune vraie donnée touchée) : soumission de décision → activité journalisée + revue écartée ; pending-decisions renvoie bien le contexte ; decision-drafts dépose un brouillon pending/kind=decision ; decisions/:id/result clôture, pose next_action, journalise, et la notification arrive bien dans l'inbox AIOS (vérifiée puis supprimée) ; suppression du lead de test réussie après le fix FK (échouait avant). node --check (server.js, db.js, app.js, api.js) OK, service redémarré sans erreur.
  • - 2026-09-03 (8) — Nouvel onglet "Leads en pause" : recap IA + 5 derniers messages dès qu'un lead sort de la gestion IA. Demande Matt (suite à un test en live sur Letycia Tang qui a révélé qu'un RDV pouvait être réservé sur un lead sans WhatsApp — supprimée sur sa demande, DELETE /api/leads/:lid, testé au préalable) : "me faire un onglet dès qu'un lead se met en pause car c'est une situation plus complexe. Dedans un recap de conversation + 5 derniers messages, et je décide direct sur le CRM ce qu'il faut faire pour la suite." DB (db.js) : nouvelle table pause_reviews (lead_id, source, reason, paused_at, summary, status pending/done). BACKEND (server.js) : helper central flagPauseForReview(leadId, reason, source), idempotent (pas de doublon tant qu'une revue pending existe déjà pour ce lead), branché sur les 3 endroits où le tag ia_off est posé : (1) POST /api/ai/handover (l'IA juge le proprio "chaud"), (2) le webhook WhatsApp quand une reprise manuelle de Marlon est détectée, (3) PATCH /api/leads/:lid quand le tag est coché à la main dans l'app (détection de la transition off→on). Deux endpoints agent (GET /api/ai/pending-pause-summaries, POST /api/ai/pause-summary) alimentent pause_review_agent.py (nouveau, cron */10 * * * *) qui rédige un recap FR court (3-5 phrases : qui/où en est le bien, pourquoi c'est complexe, sur quoi Matt doit trancher) à partir de jusqu'à 20 derniers messages + notes CRM — ne décide rien, ne rédige/n'envoie jamais de message au propriétaire. Trois endpoints UI (GET /api/pause-reviews, GET /api/pause-reviews/count, POST /api/pause-reviews/:id/dismiss) servent le recap + les 5 derniers messages et permettent d'écarter la carte une fois la décision prise SUR LA FICHE du lead (la file n'agit jamais elle-même, aucun bouton d'action dedans à part "écarter"). FRONT : nouvel onglet nav "En pause" avec badge (renderPauseReviews/paintPauseReviews/pauseReviewCard, réutilise le style .appr-card/.wa-msgs existant), démarré au boot comme le badge Auto IA. Testé en live : PATCH tags→ia_off sur un lead de test (Eliane Roman, stage hors_asuncion, donc sans risque) déclenche bien la création de la revue ; pause_review_agent.py exécuté à la main a produit un recap pertinent et fidèle à la conversation réelle ; GET /api/pause-reviews renvoie recap + 5 messages ; dismiss fait passer le compteur à 0 ; tags du lead de test remis à vide après coup. node --check + compile Python OK, service redémarré sans erreur.
  • - 2026-09-03 (7) — Garantie : tout lead actif a un next_action + next_action_date. Demande Matt : "pour tous les clients qu'on traite plus tard (pour x ou y raison), on leur associe dans le crm un next step avec une date". Constat avant fix : sur 173 leads actifs, 156 n'avaient AUCUNE date, 12 avaient une date dépassée, 5 seulement étaient à jour. BACKFILL ponctuel (backend/backfill_next_step.py, dry-run puis appliqué) : règles par priorité — RDV programmé → date réelle du RDV (4 leads) ; texte déjà écrit à la main sans date → date seule ajoutée, texte intact (43 leads) ; tag ia_off (géré à la main) → J+3 (12) ; qualif sous IA (sans_contact/contacte/interesse) → J+2, cadence par défaut de relance_agent.py (104) ; stade rdv_pris SANS meeting en base (incohérence de donnée repérée au passage : Júlio, Maria Quiroga, Paola Olivia Gonzalez Flecha, Nilse Alarcon, Rodrigo Gabaglio) → "à vérifier", J (5). Résultat : 168 fiches mises à jour, 173/173 ont désormais texte+date. TEMPS RÉEL (pour que ça reste vrai sans attendre le prochain backfill) : book-rdv pose next_action_date = date du RDV à la réservation ; waSendReply fait rouler la date de +2j à chaque envoi WhatsApp sur un lead en qualif (sauf si next_action a été tapé à la main, marqueur [auto] = seule note que le système s'autorise à réécrire) ; runDocFollowups (§ entrée (6)) pose la date à chaque transition (vu/relancé/stale) ; projection-doc-sent pose J+1 dès l'envoi. GARDE-FOU quotidien (backend/next_step_watchdog.py + run_next_step_watchdog.sh, cron 30 8 * * *, purement mécanique, aucun appel claude) : liste chaque matin les fiches actives sans date valide (grâce de 2j pour un lead tout juste créé) et notifie Matt (bell) — sinon silencieux. Exclusions : gagne/perdu/hors_asuncion (clos) et tag non_prospect. Testé : node --check OK, service redémarré sans erreur, book-rdv testé en live sur une fiche de test (date correctement posée), fiche + meeting de test remis dans leur état d'origine (status='cancelled', pas de suppression), watchdog exécuté à la main juste après le backfill → "OK: tous les leads actifs ont un next step + date valide."
  • - 2026-09-03 (6) — Nouveau : suivi automatique "document vu" (étape 3 du process qualif de Matt), sans validation manuelle pour cette étape. Constat de Matt (revue complète des 174 leads) : le process en 4 étapes (1. collecte infos → 2. génère + fait valider le document 1 page → 3. s'assure que le proprio l'a bien pris en compte, relance à 24h de silence sinon → 4. propose la vidéollamada) n'était PAS suivi après l'envoi : projection_gate_agent.py générait bien les documents mais rien ne surveillait ensuite s'ils avaient été vus (11 documents accumulés sans suivi entre le 02/09 et le 03/09). Matt a validé explicitement que cette étape 3 tourne SANS validation humaine (seul l'envoi initial du document reste gaté par lui). DB (db.js) : nouvelle table projection_docs (lead_id, pdf_path, wa_number, sent_at, status pending/relance_sent/seen/stale/closed, relance_sent_at, seen_at). BACKEND (server.js) : POST /api/ai/projection-doc-sent (agentAuth) enregistre l'envoi ; cron interne runDocFollowups() (setInterval 15 min, comme runRdvReminders) ferme la boucle tout seul — contrairement à confirm_rdv_agent.py, PAS besoin de jugement IA ici (juste "a-t-il répondu depuis l'envoi ?" et "24h/48h se sont-elles écoulées ?", purement mécanique) : réponse entrante détectée → seen + activité + notif Matt ("à vous de proposer la vidéollamada") ; 24h de silence → relance auto templée ES/FR (docFollowupRelanceText, réutilise rdvIsFr) envoyée via waSendReply(..., {auto:true}), statut relance_sent ; 48h de silence supplémentaires après la relance → stale + notif "main humaine requise" ; si le lead a avancé autrement entretemps (RDV pris, AUTO_STOP_STAGES, tag ia_off) → closed silencieux, pas de relance parasite. backend/manual_reply.py : nouvel argument --lead-id (avec --pdf) qui appelle automatiquement projection-doc-sent après un envoi réussi, pour ne plus dépendre de la mémoire de Claude. Testé : node --check + compile Python OK, service redémarré sans erreur, endpoint testé en live sur un lead réel (insertion vérifiée en base), ligne de test nettoyée (status='closed', pas de DELETE). Portée volontairement étroite : ne gère QUE "vu ou pas vu + relance à 24h" (étape 3) ; l'étape 4 (proposer la vidéollamada) reste manuelle, déclenchée par la notif.
  • - 2026-09-03 (5) — manual_reply.py : filet de sécurité de style pour les envois manuels de Claude. Constat de Matt : les 9 messages envoyés à la main par Claude aujourd'hui (via curl direct sur /api/whatsapp/thread/reply) violaient TOUS la règle "jamais de ¿/¡ ouvrant" (§ WORKFLOW ai_draft_agent.py), alors que les agents automatisés ont ce nettoyage EN DUR dans le code depuis le 01/09 (draft.replace("¿","").replace("¡","")) précisément parce qu'une consigne texte seule n'est pas fiable à 100%. En passant par curl brut, Claude contournait ce filet. Nouveau script backend/manual_reply.py : prend --number+--text (ou --file), applique le même nettoyage que les agents, puis envoie via /api/whatsapp/thread/reply (mint un JWT admin à la volée comme avant). --dry-run pour prévisualiser sans envoyer. Ne touche PAS aux messages tapés par un humain dans l'app (seul le chemin manuel de Claude est concerné). Testé (--dry-run : 2 caractères retirés correctement sur un texte de test). À utiliser systématiquement à la place d'un curl direct pour tout envoi manuel de Claude désormais.
  • - 2026-09-03 (4) — Verrou structurel : un RDV "réservé" n'est "confirmé" que si le prospect a dit oui explicitement. Suite à l'entrée (3) ci-dessous (constat), Matt a demandé un vrai correctif plutôt qu'un rappel comportemental seul. DB (db.js) : migration additive meetings.prospect_confirmed (pending|yes|no, défaut pending) + prospect_confirmed_at + prospect_confirmed_note. BACKEND (server.js) : GET /api/ai/pending-rdv-confirmations (agentAuth) liste les RDV scheduled+pending avec au moins un nouveau message reçu depuis la réservation (renvoie aussi 5 messages d'avant, pour donner à l'IA le contexte de LA proposition, pas juste la réponse isolée) ; POST /api/ai/rdv-confirmation (agentAuth) enregistre le verdict + journalise une activité + notifie Matt UNIQUEMENT si le verdict change (yes/no), reste muet si toujours pending. Le libellé "RDV confirmé" a été retiré de book-rdv (notif + activité de changement de stade) : un booking crée un RDV "à confirmer", jamais "confirmé". NOUVEL AGENT confirm_rdv_agent.py (+ wrapper run_confirm_rdv_agent.sh, cron */10 * * * *, log /root/aios/logs/mp-confirm-rdv-agent.log) : lit les RDV en attente avec du nouveau, demande au CLI claude un jugement STRICT (règle d'or : "yes" seulement si accord clair et non ambigu sur CE créneau précis ; un "ok"/accusé de réception vague reste "pending" ; défaut = pending, jamais yes par excès de confiance). Ne rédige ni n'envoie aucun message, uniquement un verdict de statut. FRONT : nouvelle colonne "Confirmation" dans la vue Rendez-vous (meetConfirmChip, 3 badges ✓/✗/⏳, CSS .meet-confirm-*). Backfill : Evelyn Castiñeira (avait dit "sí, estaré atenta" avant son RDV du 27/08) repassée en yes manuellement pour cohérence historique ; Francisco Trigo et Raúl Vera Barriocanal restent pending (aucune confirmation claire reçue). Testé en live : lancé une fois à la main juste après déploiement, a bien détecté la réponse de Francisco Trigo ("Dale, estoy pendiente") et l'a correctement jugée insuffisante pour confirmer (reste pending, citation enregistrée). node --check + py_compile OK, service redémarré, migration vérifiée (colonnes présentes sur les 3 RDV existants). Règle pour l'assistant : ne plus jamais dire "RDV confirmé" sans lire ce champ, cf. mémoire rdv-confirmation-vs-envoye.
  • - 2026-09-03 (3) — Constat (pas un bug à corriger, une limite à connaître) : aucun accusé de réception/lecture sur les messages WhatsApp sortants. Remonté par Matt sur Raúl Vera Barriocanal : un RDV avait été booké (calendrier + message envoyé) et présenté comme "confirmé" alors que le prospect ne semblait même pas avoir vu le message. Vérifié dans wa_evolution.js/server.js : aucune capture des événements Evolution de type messages.update/ACK (delivered/read), wa_messages n'a pas de colonne de statut de livraison pour les sortants (la colonne read existe mais sert au tri des ENTRANTS, pas à savoir si un sortant a été vu). Donc le CRM (et l'assistant) n'a aucun moyen actuel de savoir si un message envoyé a été livré ou lu, seulement qu'il est parti (présence d'un wa_message_id). Pas de fix appliqué (changement de comportement de l'assistant plutôt que du code : ne plus annoncer "confirmé" sans réponse explicite du prospect). Piste future si Matt la demande : Evolution API expose ces ACK par webhook (MESSAGES_UPDATE), il faudrait écouter cet event et ajouter une colonne delivery_status sur wa_messages.
  • - 2026-09-03 (2) — Correction fiche Evelyn Castiñeira : RDV du 27/08 jamais clôturé. Remonté par Matt (l'assistant avait rapporté ce RDV comme "en cours" alors qu'il datait de 7 jours). Le meeting (W1tIg7cUp9RJoRb5) était resté status='scheduled'/outcome=NULL malgré la date passée : status mis à done + outcome renseigné (elle a assisté, a dit vouloir évaluer et revenir vers nous, silence depuis). Lead passé de rdv_pris à rdv_termine (stage existant, juste jamais utilisé automatiquement), next_action posé (relancer). Constat plus large : rien dans l'app ne clôture jamais un RDV automatiquement (renderMeetings classe bien passé/à venir par date, mais aucun bouton "marquer terminé"/"no-show" n'existe, donc rdv_pris peut rester coincé indéfiniment après la date). Pas de correctif de code fait ici (juste la donnée) ; à discuter avec Matt si ça vaut le coup d'ajouter une relance automatique ou un flag "RDV passé sans clôture" sur le dashboard.
  • - 2026-09-03 — Onglet « À approuver » retiré de la nav + 9 fiches non-propriétaires masquées du pipeline. Deux demandes de Matt. (1) Onglet « À approuver » retiré (frontend/index.html : bouton nav supprimé ; app.js : appel startApprovalsBadge() retiré au boot). Le code sous-jacent (table ai_drafts, endpoints /api/ai/drafts*, renderApprovals/draftCard/modeSwitch dans app.js) est laissé en place et continue de tourner : rappel important, la file d'approbation reste le SEUL endroit où partent les réponses/relances gardées côté serveur (autoEligible : messages argent/commission, leads déjà rdv_pris/gagne/perdu, numéro non rattaché — cf. §5). Avec l'onglet retiré, ces messages-là ne seront plus jamais vus ni envoyés par personne (15 en attente au 03/09, dont certains depuis le 25/08 : essentiellement des échanges post-RDV sur des leads déjà rdv_pris, ex. Nilse Alarcon, Francisco Trigo, Marcela Sosa, Raul Vera, + 1 vraie question commission Gerardo/20%). Point ouvert avec Matt, pas tranché : soit on assume ce silence sur les fils post-RDV (l'IA s'arrête déjà là par design), soit on route ces cas vers la notif AIOS/WhatsApp de Marlon au lieu d'un onglet CRM. Le cron run_relance_agent.sh (qui alimente aussi cette file pour les relances) a été laissé actif (une tentative de coupure a été faite puis annulée dans la même session : le routage serveur envoie déjà l'essentiel des relances en auto depuis le 2026-08-25, seul le sous-ensemble argent/clos reste gaté). (2) 9 fiches taguées non_prospect (en plus de ia_off déjà présent) : Mathis Delettre (agent partenaire), Sebastien Beaupied (associé), Bristol (contact pub), Carmelo (candidature emploi), Sumi Espinoza + FIXA PROFESIONAL (déjà clients Airbnb), Portero Altavida (gardien), Femme de ménage, WhatsApp +554598556368 (hors périmètre). GET /api/leads (server.js) exclut désormais tags LIKE '%non_prospect%' par défaut (sauf si un tag= explicite est demandé) : elles disparaissent du Pipeline et de « Propriétaires » (seuls consommateurs de cet endpoint), restent lisibles via /api/leads/:lid et gardent 100% de leur historique WhatsApp/activités (rien supprimé, wa_messages/activities intacts). Une note a été journalisée sur chaque fiche. node --check OK, service redémarré, vérifié : 224/233 leads visibles (233-9), site répond 200.
  • - 2026-09-02 (3) — Correction : les 6 leads ia_off du 02/09 n'étaient PAS un bug. Suite à l'entrée (2) ci-dessous, vérification détaillée (logs wa_messages+activities seconde par seconde) : sur les 6 leads (Manu, Dinka, Sandy, Stefany, Vivi, Diego) que j'avais diagnostiqués "faux positif webhook" et remis en auto, 4 avaient en fait été mis en pause par le mécanisme VOLONTAIRE "IA en pause (proprio chaud) -> passation Marlon" (lead qualifié, vocal à écouter, ou négociation), et 2 (Manu, Diego) ont un vrai message sortant [message non texte] avec un wa_message_id au format différent de ceux générés par l'API (3EB0... côté API vs A5B1.../A56D... côté app), signe d'un vrai geste manuel de Marlon dans l'appli WhatsApp. Tag ia_off remis sur les 6. Le fix normalisation/fenêtre 180s de l'entrée (2) reste en place (durcissement préventif raisonnable) mais n'était pas la cause de l'incident. Leçon retenue : avant de conclure "bug", toujours vérifier le wa_message_id et le contenu exact du message sortant qui a déclenché la reprise manuelle, pas juste le timing.
  • - 2026-09-02 (2) — Fix DELETE /api/leads/:lid : FOREIGN KEY constraint failed sur tout lead avec brouillon/RDV. L'endpoint ne nettoyait que activities+lead_properties avant de supprimer la fiche ; ai_drafts et meetings ont aussi une FK sur leads(id), donc la suppression échouait dès qu'un lead avait ne serait-ce qu'un brouillon IA (= quasi tous les leads réels). Ajout de DELETE FROM ai_drafts/meetings WHERE lead_id=? + détachement (SET lead_id=NULL) de wa_messages/ai_feedback (pas de FK dessus, conversation gardée comme historique orphelin plutôt que supprimée). node --check OK, service redémarré, testé en live (suppression d'un lead de test réussie).
  • - 2026-09-02 — projection_gate_agent.py génère aussi le teaser PDF 1 page (demande Matt). Avant : le cron ne calculait que la fourchette chiffrée et notifiait Matt en texte. Après : pour chaque lead "prêt" (zone en base AirDNA + type connu), génère en plus le document 1 page (nouveau script du skill moa-properties-projection : generate_projection_doc_1page.py, même charte/CSS que le dossier 3 pages Dossier-XXX-MOA-ES.pdf mais condensé sur une page, sans carte Mapbox) dans workspace/MOA-Properties/teasers/Estimacion-<slug>-MOA-ES.pdf. Le chemin est ajouté à la ligne de notification. Ce teaser est le document envoyé gratuitement au proprio PENDANT la qualification (avant la réunion complète), distinct du dossier 3 pages (réservé à plus tard/au RDV). Le script ne parle et n'envoie toujours rien seul : génération auto, envoi validé manuellement par Matt en chat avec Claude (pas de nouvelle UI d'approbation, décision explicite de Matt de garder la validation en conversation plutôt que dans l'appli). py_compile OK, testé en dry-run (12 leads déjà en attente détectés) + génération réelle d'un PDF de test (OK, nettoyé après).
  • - 2026-08-20 — Auto-refresh sur nouvelle version déployée, sans déconnexion (parité avec moa-crm). BACKEND (server.js) : GET /api/version (public) = max des mtimes de index.html/styles.css/js/app.js/js/api.js (change à chaque déploiement). FRONTEND (js/app.js) : setupVersionWatch() appelé depuis showApp(), sonde /api/version toutes les 90 s ; à changement, recharge immédiatement si l'agent est inactif (isSafeToReload : aucune .overlay, aucun champ focus), sinon bannière « Nouvelle version disponible · Recharger » + reload auto dès qu'il est libre. Token en localStorage (moa_crm_token) → reload garde la session, aucune déconnexion. Pas de i18n (UI FR, chaînes en dur), pas de SW ici. import fs ajouté. Cache-bust js/app.js?v=20260820ver. node --check server/app OK, service redémarré, /api/version répond (8333), aucun erreur JS au boot. Concerne l'équipe (Marlon a accès).
  • - 2026-08-14 (soir³) — Vue « Propriétaires » : tri alphabétique par défaut + tri au clic sur les colonnes (demande Matt). Avant : la table renderLeads (frontend/js/app.js) affichait les leads dans l'ordre renvoyé par l'API (par activité). Après : tri client avec état { key, dir } (défaut name / asc = ordre alphabétique des propriétaires). renderLeads scindé en load() (fetch + stockage dans rows) et draw() (tri + rendu) ; les filtres (stade/agent/étiquette) rappellent load(), le tri seul rappelle draw() (pas de refetch). Les 8 en-têtes sont maintenant cliquables (.sortable, curseur pointer, flèche ▲/▼ sur la colonne active) : clic sur une colonne trie par cette colonne, re-clic inverse le sens. Accesseurs par colonne : Prospect→nom, Stade→ordre du pipeline (state.meta.stages), Étiquettes→1re étiquette, Budget→est_revenue (numérique), Source, Agent→nom assigné, Prochaine action, Activité→last_activity_at ; valeurs vides poussées en fin de tri. Comparaison via localeCompare('fr', {sensitivity:'base', numeric:true}). Front only, cache-bust js/app.js?v=20260814sort. node --check app.js OK. Vérifié : le serveur sert bien la nouvelle logique et le script boote sans erreur JS (seuls des 401 attendus hors session). Reste : confirmation visuelle connectée (nécessite un login équipe) : à valider par Matt en ouvrant l'onglet « Propriétaires ».
  • - 2026-08-14 (soir²) — Étiquettes WhatsApp NATIVES « IA » (rouge) / « Marlon » (vert) synchronisées sur le compte WhatsApp Business. Demande Matt : voir dans la VRAIE app WhatsApp (pas seulement dans le CRM) quelles conversations sont gérées par l'IA vs par Marlon. Prolonge la pastille interne auto_zone vers les étiquetas natives du compte WhatsApp Business (bidirectionnelles, visibles dans l'app WhatsApp). Périmètre IA (rouge) = lead en QUALIF_STAGES (sans_contact/contacte/interesse) sans RDV ; tout le reste = Marlon (vert). wa_evolution.js : findLabels(name) (GET /label/findLabels/{instance}) + handleLabel(name,number,labelId,action) (POST /label/handleLabel/{instance}, action add|remove). server.js : helpers autoZoneForNumber, waInstanceForNumber, resolveWaLabels (cache id d'étiquettes par instance, _waLabelCache), syncWaLabel(number) (fire-and-forget, pose IA ou Marlon + retire l'autre ; no-op tant que les étiquettes n'existent pas côté WhatsApp), syncWaLabelForLead(leadId). Noms configurables via .env WA_LABEL_AI (défaut « IA ») / WA_LABEL_HUMAN (défaut « Marlon »). Accroches : webhook entrant (recale à chaque message), changement de stade (PATCH lead), RDV créé (POST meetings → repasse en Marlon), RDV supprimé (DELETE meetings → peut repasser en IA). Endpoints : POST /api/whatsapp/labels/sync-all (backfill toutes conversations, admin/WA) et GET /api/whatsapp/labels (diagnostic : étiquettes de la ligne connectée). Rappel WhatsApp : la CRÉATION d'une étiquette ne passe PAS par l'API (Baileys) ; les 2 étiquettes « IA » (rouge) et « Marlon » (vert) doivent être créées UNE FOIS dans l'app WhatsApp Business du numéro MOA Properties (595971612505), ensuite le CRM les pose/retire seul. node --check server/wa_evolution OK, service redémarré, health 200. En attente : création des 2 étiquettes côté app (au 14-08 soir, la ligne n'a que Favoris/Non lues/Groupes) puis lancer le backfill.
  • - 2026-08-26 — Placement des projets nommés (ne plus écarter un lead sur un nom de projet). Demande Matt : si le proprio donne un nom de projet/edificio, l'IA doit le PLACER (déterminer sa zone) au lieu de le juger "trop vague". Nouveau data/known_projects.txt (table projet -> zone, ex. Zuba* -> Fernando de la Mora norte [vérifié web], Insignia/Campo Grande -> Luque, SOHO Flats -> Villa Morra, The Avenue/Home Herrera/Filum -> Barrio Herrera, Boston -> Las Mercedes ; piège "Insignia Club San Bernardino" = HORS ZONE) injecté dans build_prompt des deux agents, avec règle : placer via la table / sa connaissance / en demandant un repère, jamais rejeter sur le seul nom. Fiche Vivi Ortiz corrigée (Zuba 19 -> Fernando norte, zone couverte). py_compile OK.
  • - 2026-08-26 — Invitation RDV 100% auto (Matt + Marlon + prospect). Quand le prospect accepte un créneau proposé, l'IA envoie automatiquement l'invitation Google Agenda. mp_calendar.py : MATT_EMAIL + MARLON_EMAIL (bnbqualitypro, distinct de l'agenda "Famille" lu pour la dispo), proposable_slots(n) (créneaux avec ISO), book_rdv(iso, prospect_email, name) -> create_rdv (events.insert sur le primaire de Matt, sendUpdates=all -> invite les 3). ai_draft_agent.py : au stade « Projection envoyée », injection des créneaux AVEC leur ISO ; contrat JSON rdv_iso ; quand rdv_agreed=true + rdv_iso valide, l'agent (a) poste d'abord la confirmation (auto-envoyée tant que stade=proposition), (b) appelle book_rdv (crée l'événement + invitations), (c) POST /api/ai/book-rdv. server.js : nouvel endpoint agentAuth POST /api/ai/book-rdv (insère le meeting, passe rdv_pris, passation à Matt avec le lien de l'invitation ; anti-doublon par (lead, starts_at)). Le meeting compte désormais dans le cap 2/jour (mp_calendar._moa_rdv). Fallback : si rdv_agreed sans rdv_iso exact (le proprio propose une heure hors liste), ancien chemin = passation manuelle (pas d'événement auto). Testé : écriture Google Agenda OK (event créé/supprimé, sans invités) ; endpoint book-rdv -> meeting + proposition→rdv_pris + passation (lead jetable, nettoyé). node --check + py_compile OK, service redémarré.
  • - 2026-08-25 (soir) — Politique horaires RDV (demande Matt). RDV pris par l'IA = mardi→vendredi, de préférence 12h-15h, max 2/jour, repli sur créneaux plus larges seulement si le proprio ne peut aucun. Encodé dans mp_calendar.py : ALLOWED_WEEKDAYS={1,2,3,4}, PREF 12-15h / WIDE 9-17h, MAX_PER_DAY=2 (compte les RDV déjà planifiés via la table meetings), slots 45 min ; free_slots(wide=) + proposable_text(n) (un créneau par jour distinct, libellés ES). Les deux agents (ai_draft_agent/relance_agent) : bloc WORKFLOW « POLITIQUE HORAIRES RDV » + injection des créneaux filtrés dans le prompt quand le lead est au stade « Projection envoyée ». MAJ 2026-08-26 : agenda de Marlon branché. Il a partagé un agenda SECONDAIRE nommé « Famille » (id family01255998827217337924@group.calendar.google.com), lisible avec le scope calendar.events actuel. L'id est configurable via data/marlon_cal_id.txt (ou env MP_MARLON_CAL_ID), lu par mp_calendar._marlon_cal_id(). check-access → les 2 agendas « lisibles » ; les créneaux sont désormais l'intersection Matt ∩ Marlon (vérifié : un créneau occupé chez Marlon disparaît bien de la liste). Reste : câbler la création auto de l'invitation aux 2 adresses (+ prospect) quand le prospect confirme un créneau précis (create_rdv() existe déjà, manque la capture du créneau choisi).
  • - 2026-08-25 (soir) — L'IA exploite le formulaire pub (ne redemande plus une info déjà donnée). Demande Matt (ex : si le proprio a dit "studio" au formulaire, ne pas redemander la typologie). (1) lib/meta_leads/app.py : à l'ingestion, le formulaire est désormais mappé sur les champs STRUCTURÉS du lead — _property_unit_type() (casa/studio/1 dorm/2 dorm/3 dorm) + zone (dónde_se_encuentra), rental_status (actualmente_alquilas), timeline (cuándo empezar), en plus du bloc brut intake_data. (2) ai_draft_agent.py + relance_agent.py build_prompt : ajout des champs furnished, rental_status, timeline au contexte CRM + consigne explicite « le proprio a DÉJÀ rempli le formulaire, NE REDEMANDE JAMAIS une info déjà connue ». (3) Backfill des leads existants depuis leur intake_data : type rempli sur 98, zone 105, situation 94, timing 105. Service aios-meta-leads redémarré ; agents pris au prochain cron. py_compile OK.
  • - 2026-08-25 (soir) — Style des messages auto (demande Matt) : pas de « ¿ », texte aéré. Dans les WORKFLOW de ai_draft_agent.py + relance_agent.py : bloc STYLE (jamais de point d'interrogation ouvrant « ¿ », retours à la ligne quand plusieurs phrases) + filet déterministe res["draft"].replace("¿","") à l'envoi. Exemple interne ¿te sirve...? corrigé. Le 1er message (lib/meta_leads/app.py: build_personal_message) : « ¿ » retiré + découpé en 3 lignes (service aios-meta-leads redémarré). « ¡Hola! » conservé (exclamation, pas une question). Agents pris au prochain run cron (pas de restart).
  • - 2026-08-25 (soir) — Reprise manuelle = mode manuel (Marlon écrit → IA coupée). Demande Matt : si Marlon répond à la main dans une conversation, elle passe en manuel et on arrête les automatisations. server.js webhook POST /api/whatsapp/webhook/:instance : détection d'un SORTANT non émis par l'IA/le CRM. Un message fromMe est considéré manuel si (a) son wa_message_id n'existe pas déjà en base (les envois IA passent par waSendReply qui insère la ligne AVANT l'écho webhook, même key.id → dédup), (b) ce n'est pas un doublon récent (≤120s, même corps, garde anti-faux-positif si l'API ne renvoie pas d'id), et (c) le fil a DÉJÀ un entrant (protège le 1er contact manuel qui précède l'IA). Si manuel → le lead reçoit le tag ia_off (stop total réponses + relances, cf. entrée précédente), activité note « Reprise manuelle », et purge d'un éventuel brouillon en attente. Testé en live (webhook simulé) : message manuel Marlon (id neuf, corps différent) → lead ia_off + trace ; écho d'un message IA (id déjà en base) → AUCUNE bascule ; leads de test supprimés. node --check OK, service redémarré. Rappel : lecture de contexte OK (l'agent reçoit tout l'historique du fil, sans limite, dans les deux files).
  • - 2026-08-25 (soir) — Coupe-circuit IA par lead (tag ia_off). Demande Matt (« arrête toute conversation automatique avec ce lead »). Nouveau tag ia_off (db.js TAGS) : quand un lead le porte, il est exclu de TOUT l'auto, quel que soit son stade. server.js : helper aiOff(tags) ; check dans autoEligible (bloque l'auto-envoi), et continue dans GET /api/ai/pending-threads + GET /api/ai/silent-threads (l'agent ne le drafte même plus). Le leadRow de POST /api/ai/drafts sélectionne désormais tags. Appliqué à Betania Plate (y5UOHKWYwDgyT3Vw) + purge de son brouillon en attente. Vérifié : absente des deux files. node --check OK, service redémarré.
  • - 2026-08-25 (soir) — Auto-remplissage fiche + avance auto du pipeline pendant que l'IA répond. Demande Matt : « tant que l'IA répond, qu'elle remplisse la fiche CRM et fasse avancer le lead dans le pipeline ». Le remplissage de fiche existait déjà (/api/ai/lead-extract, remplit les champs VIDES + recalcule l'estimation). Ajouté l'avance de pipeline : (1) db.js — réordonnancement STAGES/PIPELINE_KEYS pour l'Option B : proposition (Projection envoyée) passe AVANT rdv_pris (la projection est envoyée avant le RDV). Aucune logique n'est indexée sur l'ordre (juste l'affichage kanban). (2) server.js POST /api/leads/:lid/meetings — créer un RDV promeut désormais le lead en rdv_pris (depuis sans_contact/contacte/interesse/proposition) + activité stage. (3) server.js POST /api/ai/lead-extract — nouveau signal crm.rdv_agreed : quand le propriétaire ACCEPTE un RDV, le lead passe rdv_pris (l'IA s'arrête, cf. AUTO_STOP_STAGES) et une passation est poussée à Matt via notifyOperator() (helper spawn python3 lib/notify.py, best-effort) avec contact + bien + estimation interne + créneau proposé (crm.rdv_note). Les nudges existants (sans_contact→contacte sur inbound, →interesse sur zone+type) sont conservés. (4) ai_draft_agent.py — contrat CRM enrichi (rdv_agreed/rdv_note + consigne WORKFLOW : true seulement si accord clair), et le POST d'extraction se déclenche aussi quand rdv_agreed seul est vrai. Testé en live (lead jetable) : extraction zone/type/meublé → fiche remplie + sans_contact→interesse + estimation 170 ; rdv_agreed=true → interesse→rdv_pris + passation notifiée ; lead supprimé. node --check server/db OK, py_compile agent OK, service redémarré. Reste : passer le lead en proposition au moment où la projection est réellement envoyée (aujourd'hui déclenché à la main après validation Matt, cf. porte de projection).
  • - 2026-08-25 — Onboarding IA en TOUT-AUTO + garde-fou "porte de projection" (Option B). Demande Matt : (1) plus de file de validation humaine, l'IA mène toute la conversation seule en live (réponses ET relances) ; (2) stratégie Option B = on ENVOIE la projection au prospect puis on pousse au RDV ; (3) garde-fou dur : avant TOUT envoi de projection, une notification à Matt pour qu'il valide le document, et si données manquantes pour projeter, une notification aussi. server.js : autoEligible() assoupli (voir section 5) — retrait des conditions kind=reply / confidence=high / auto_safe / QUALIF_STAGES ; ne restent que le blocage argent (AUTO_FORBIDDEN_RE), l'arrêt post-RDV (AUTO_STOP_STAGES + meetings), et lead rattaché. Mode basculé shadow → live (app_settings.ai_auto_mode). ai_draft_agent.py : règle hors-zone réécrite (on ne ferme jamais la porte : "por ahora no cubrimos, pero te guardo el contacto y apenas abramos te escribo"). Nouveau projection_gate_agent.py + run_projection_gate.sh (cron 15 * * * *) : scan read-only des leads interesse/proposition sans RDV, mapping zone texte→clé AirDNA + type→estimate.py, notif digest à Matt (lib/notify.py) « X prêtes à valider / Y données manquantes », dédup data/projection_gate_state.json. Ne parle jamais au prospect. Testé : node --check server OK, service redémarré (mode live vérifié) ; dry-run agent projection = 8 prêtes / 18 manquantes sur données réelles ; 1re notif réelle envoyée à Matt (id ntf renvoyé). Reste : quand Matt aura validé assez de docs, passer l'ENVOI de la projection au prospect en auto lui aussi (aujourd'hui déclenché après son OK).
  • - 2026-08-14 (soir) — Pastilles 🔴/🟢 sur les fils WhatsApp (périmètre auto vs manuel). Demande Matt : voir d'un coup d'oeil quelles conversations sont dans le périmètre de qualification automatique (IA) pour ne plus y répondre à la main, vs les manuelles. Backend (server.js) : GET /api/whatsapp/inbox et GET /api/whatsapp/thread renvoient un flag auto_zone (bool) = le fil est rattaché à un lead en stade de qualification (QUALIF_STAGES = sans_contact/contacte/interesse) ET aucun RDV (meetings) pour ce lead. C'est exactement le périmètre où l'IA prend la main (cf. autoEligible). Tout le reste (RDV pris/terminé, proposition, gagné/perdu, ou numéro non rattaché « à trier ») = auto_zone:false. Front (app.js) : pastille 🔴 Auto IA (wa-auto-red) quand auto_zone, sinon 🟢 Manuel (wa-auto-green), affichée sur chaque ligne de l'inbox WhatsApp ET dans l'en-tête du fil ouvert. CSS .wa-auto/.wa-auto-red/.wa-auto-green (styles.css). Cache-bust ?v=20260814watags. Nuance : le 🔴 marque le PÉRIMÈTRE qualif (là où l'IA agit) ; l'IA n'envoie réellement seule que si le mode d'auto-envoi est Actif (défaut = Ombre). Vérifié en live (JWT admin forgé) : 24 fils → 8 🔴 (sans_contact…) / 16 🟢 (proposition, rdv_pris, rdv_termine, à trier), mapping stade→couleur correct. node --check server/app OK, service redémarré.
  • - 2026-08-14 — Auto-envoi des réponses de qualification (mode ombre/actif) + remplissage AUTO de la fiche (Phase 2). Demande Matt : « auto-envoi tant que c'est la phase de qualification (avant le 1er RDV), avec un mode ombre pour voir si tout est OK et pouvoir commenter les messages envoyés ; et que la fiche se remplisse toute seule au fil de la qualif ». Modèle : le serveur est l'AUTORITÉ du routage (l'agent ne fait que proposer). Un brouillon devient auto UNIQUEMENT si kind=reply + confidence=high + auto_safe=true + lead rattaché + stade ∈ {sans_contact, contacte, interesse} + AUCUN RDV pour le lead + message NON sensible (garde AUTO_FORBIDDEN_RE sur prix/commission/estimation). Trois modes (app_settings.ai_auto_mode, kill-switch, défaut shadow) : off (tout en file humaine), shadow/ombre (l'IA rédige ce qu'elle enverrait mais N'ENVOIE PAS ; visible dans « Auto IA »), live/actif (envoi réel des réponses qualif haute confiance ; le reste reste à approuver). db.js : app_settings(key,value) ; ai_drafts.auto (0/1) + nouveau statut shadow ; leads.furnished + leads.rental_status. server.js : helpers getSetting/setSetting/autoMode, QUALIF_STAGES, AUTO_FORBIDDEN_RE, autoEligible() ; routage async dans POST /api/ai/drafts (live→waSendReply({auto:true}) sinon fallback file ; shadow→statut shadow ; sinon pending) ; endpoints GET/POST /api/ai/settings, GET /api/ai/config, POST /api/ai/lead-extract (remplit les champs VIDES seulement, normalise unit_type + furnished, fusionne property_cities, recalcule l'estimation, nudge de stade en qualif only), GET /api/ai/auto-feed(/count) ; send/reject acceptent désormais le statut shadow ; pending-threads ne régénère plus un candidat ombre déjà produit pour le même message entrant ; furnished/rental_status ajoutés à LEAD_FIELDS (éditables en fiche). ai_draft_agent.py : contrat JSON enrichi (auto_safe + objet crm), poste l'extraction sur /api/ai/lead-extract (même sans brouillon), transmet auto_safe. Front (app.js/api.js/index.html/styles.css, cache-bust ?v=20260814autosend) : segmented control Off / Ombre / Actif (dans « À approuver » et « Auto IA », confirmation sur passage en Actif) ; nouvelle vue « 🛰 Auto IA » + badge (compteur de candidats ombre) = cartes {ombre non-envoyé éditable → Envoyer/Écarter, ou envoyé auto} avec « 💬 Commenter / améliorer » (feedback réinjecté à l'IA). Testé en live (JWT admin forgé + jeton agent, fiche de test créée puis supprimée) : mode par défaut = shadow ; brouillon éligible → shadow ; lead-extract remplit zone/type/meublé (normalisés) + pousse contacte→interesse ; message « comisión 20% » → forcé en file (garde anti-fuite) ; lead en rdv_pris + mode live → file humaine, aucun envoi tenté (garde hors-qualif) ; POST settings invalide → 400 ; assets servis 200. node --check server/db/app/api OK, py_compile agent OK, service redémarré. Non exécuté volontairement : un envoi WhatsApp réel en mode live (comms externe ; le fallback en cas d'échec d'envoi est en place). NB : le mode est laissé sur ombre pour la période d'observation demandée.
  • - 2026-08-13 (soir) — Fix pièces jointes WhatsApp : nginx plafonnait le corps à 1 Mo (413). Symptôme Matt : impossible d'envoyer une pièce jointe dans un fil WhatsApp (« j'ai tenté un pdf ça m'a mis erreur »). Diagnostic complet : (a) l'endpoint POST /api/whatsapp/thread/reply-media parse bien le binaire brut (express.raw 16 Mo) et l'anti-ban répond 409 sur numéro froid (OK) ; (b) le payload Evolution /message/sendMedia est correct (test réel d'un PDF sur la ligne pro elle-même → documentMessage envoyé, status PENDING). Le vrai goulot = nginx : les deux vhosts (crm-properties.panelbay.com + crm-properties.bouthors-m.tenga.run) n'avaient AUCUN client_max_body_size, donc la limite par défaut 1 Mo s'appliquait ; un dossier PDF (~1 Mo) déclenchait un 413 avant d'atteindre le CRM, d'où l'« erreur » côté module. Correctif : client_max_body_size 20m; ajouté dans le location / des deux fichiers /etc/nginx/sites-enabled/crm-properties.*.conf (marge sous le plafond applicatif de 16 Mo), nginx -t OK, systemctl reload nginx. Vérifié : PDF de 1,5 Mo POST via l'URL PUBLIQUE https://crm-properties.panelbay.com sur numéro froid → 409 anti-ban (le fichier traverse nginx et atteint l'app ; avant = 413). Aucun changement de code ni de service applicatif. NB : un PDF de test a été envoyé une fois sur la ligne pro MOA Properties (595971612505, message à soi-même) pour valider Evolution ; sans conséquence.
  • - 2026-08-13 — Relances automatiques des propriétaires silencieux (Phase 1b, human-in-the-loop). Suite de la Phase 1 (réponses IA) : demande Matt de « continuer sur l'automatisation de réponse aux prospects ». Jusque-là l'IA ne rédigeait QUE des réponses aux messages entrants ; un propriétaire qui se taisait après notre message ne recevait jamais de relance proposée. Ajouté : server.js endpoint GET /api/ai/silent-threads (agentAuth) qui détecte les fils « silencieux » à relancer = dernier message SORTANT (balle dans leur camp), au moins un message ENTRANT dans l'historique (conversation réelle, jamais du cold outreach → premier contact reste manuel), délai de silence ≥ cadence, plafond de relances non atteint. Cadence/plafond côté serveur : min_days=2 (1re relance à J+2), espacement croissant (need = min_days + relances_done → J+2 puis J+3), max_relances=2 (après 2 relances sans réponse, on laisse l'humain). Garde-fous : dossier clos (CLOSED_STAGES = gagne/perdu/hors_asuncion) exclu, RDV scheduled à venir exclu (l'humain gère), aucun brouillon déjà pending (index unique 1-pending/numéro partagé avec les réponses). Le nombre de relances déjà faites = sortants depuis le dernier entrant, moins la réponse initiale. Agent backend/relance_agent.py (calqué sur ai_draft_agent.py, helpers identiques) : GET silent-threads + feedback, prompt de RE-ENGAGEMENT dédié (relance douce ES ton Marlon, varie l'angle, règle d'or = ne PAS relancer si le lead a dit non / hors zone / la vraie action n'est pas un message / RDV déjà en cours / rien de neuf à dire), POST /api/ai/drafts avec kind=followup. Wrapper run_relance_agent.sh (log /root/aios/logs/mp-relance-agent.log), **cron 0 10 * * * (1 passe/jour, les relances sont datées). Front (app.js) : badge « 🔁 Relance » sur les cartes kind==='followup' + libellé « Relance proposée (éditable) » ; sous-titre de la vue enrichi. CSS .appr-kind/.appr-kind-relance (styles.css), cache-bust ?v=20260813relance (3 assets). Rien n'est envoyé sans approbation : les relances tombent dans la même file « À approuver ». Testé en live : node --check server/app OK, py_compile relance_agent OK, service redémarré ; endpoint = 0 sur données réelles (silences tous < 1j, seuil 2j → comportement correct) ; forcé min_days=0 → 1 candidat surfacé, claude -p a correctement refusé de relancer (should_draft=false) car notre « dernier message » était une note interne et un vocal du proprio attendait une vraie réponse humaine → règle d'or validée ; garde-fous (stages clos/RDV) filtrent bien les autres. Run wrapper réel = no-op propre. Reste (Phase 2)** : apprentissage sur édition (capter les corrections humaines au moment de l'envoi) puis éventuel auto-envoi des réponses high.
  • - 2026-08-13 : Splash de reconnexion (suppression du flash de l'écran de login au démarrage). Complément visuel du fix « Session expirée » (cookie httpOnly). Au boot, boot() retente /me via le cookie de session : pendant cette revalidation, l'écran de login clignotait avant de laisser place à l'app. Ajout d'un splash plein écran « Connexion en cours… » (marque « MOA Properties » + sous-titre « CRM », spinner) affiché par défaut, qui cède la main soit à l'app (reconnexion OK) soit au login (session réellement expirée). index.html : bloc #boot-splash inséré juste après <body>, et le conteneur #login reçoit la classe hidden par défaut (le splash reste visible jusqu'à la résolution de l'auth). styles.css : styles #boot-splash calqués sur la palette du login (fond radial-gradient(#16483A,#0A2A21) = forest, texte --cream #F6F1E7, spinner et sous-titre en --gold #C9A24B, prefers-reduced-motion géré). app.js : fonction hideSplash() appelée aux DEUX points de résolution de l'auth, showApp() (succès) ET showLogin() (échec / auth:expired), donc le login peut toujours s'afficher ; filet de sécurité setTimeout(hideSplash, 4000) posé au boot pour que le spinner ne puisse jamais bloquer l'UI de façon permanente. Frontend statique, aucun restart. Cache-bust ?v=20260813splash sur les 3 assets modifiés (styles.css, js/api.js, js/app.js). Vérifié : node --check app.js + api.js OK. Patron répliqué à l'identique du CRM MOA (moa-crm).
  • - 2026-08-13 — Système d'autorisation IA (human-in-the-loop) des réponses WhatsApp — Phase 1. Demande Matt (workflow leads MOA Properties). Une IA rédige les réponses aux propriétaires qui nous ont écrit ; rien n'est envoyé sans approbation humaine ; le premier contact reste 100% manuel (anti-ban spam) et n'est jamais concerné (la file ne traite que les conversations DÉJÀ ouvertes par le lead). db.js : 2 tables additives — ai_drafts (file d'approbation : lead_id, wa_number, trigger_msg_id/body, kind, draft_body, context_summary, confidence high/medium/low, status pending/sent/rejected, sent_body, decided_by/at ; index unique partiel uniq_ai_draft_pending = 1 seul pending/numéro) et ai_feedback (situation, guidance, lead_id, draft_id : boucle d'apprentissage). server.js : helper waSendReply(number,text) (choisit l'instance Evolution du fil ou la 1re session open, envoie via sendText, journalise le sortant + activité whatsapp) ; shapeDraft (enrichit lead + push_name) ; middleware agentAuth (X-Ingest-Token) ; 8 endpoints /api/ai/* (cf. section 5). Agent backend/ai_draft_agent.py : GET pending-threads + feedback, appelle le CLI claude -p (abonnement, JAMAIS l'API Anthropic) pour rédiger {should_draft, draft, confidence, summary} en ES (ton Marlon) avec le workflow métier injecté (objectif RDV, qualification, zones couvertes, règle d'or = pas de brouillon si la balle est dans notre camp), POST /api/ai/drafts. Ne fait des appels claude QUE s'il y a des fils en attente. Cron */15 via run_ai_draft_agent.sh (log /root/aios/logs/mp-ai-draft-agent.log). Front : nav « 🤖 À approuver » + badge compteur (poll 30s) ; vue renderApprovals = cartes {qui + étape, dernier message reçu, résumé de contexte 🧠, badge de confiance, réponse éditable, actions Approuver & envoyer / Rejeter / 🎓 Apprendre à l'IA (modale feedback + option « enregistrer + rejeter »)}. api.js : aiDrafts/aiDraftsCount/aiDraftSend/aiDraftReject/aiDraftFeedback. CSS .nav-badge, .btn.primary/.btn.ghost, .appr-*, .conf-*, .modal-card/.modal-actions. Cache-bust ?v=20260813approvals. Accès réservé admin + WA_ALLOWED_EMAILS (requireWa) ; badge/vue masqués sinon (« module non activé »). Doc de référence du workflow (intro Option A « Soy Marlon », qualif, RDV, séquence relance 5 jours multi-canal de MOA Real Estate, règle d'or, zones) figée dans /root/workspace/MOA-Properties/workflow-leads-whatsapp.md. Testé en live : node --check server/db/app/api OK ; /api/ai/pending-threads = 4 fils réels ; agent exécuté → 3 brouillons créés + 1 skip correct (Dario, propriétaire déjà onboardé = balle dans son camp, règle d'or respectée) ; les 3 brouillons (Areguá/CDE/Villarrica, tous hors zone) = messages de clôture chaleureux SANS forcer le RDV, zones respectées ; endpoints UI validés via JWT forgé (count=3, drafts enrichis lead+confiance+résumé) ; garde doublon 1-pending/numéro OK. Non testé volontairement : l'ENVOI réel WhatsApp (action de comms externe, pas déclenchée en test). Reste (Phase 2) : exploitation avancée de la boucle d'apprentissage (réinjection systématique, scoring de confiance affiné) et éventuel auto-envoi des réponses high (option désactivée par défaut).
  • - 2026-08-13 — Indicateur visuel bidirectionnel « conversation WhatsApp ↔ propriétaire ». Demande Matt (« un petit icône pour savoir qu'une conversation ou un lead est bien assigné à un numéro, et inversement »). Backend (server.js) : flag wa_linked (0/1) ajouté au shaping des leads, côté fiche (shapeLead : SELECT 1 FROM wa_messages WHERE lead_id=? LIMIT 1) ET côté liste (shapeLeadsList : 1 requête groupée waByLead, pas de N+1). Front : helper waMark(l, size) (app.js) = petit logo WhatsApp vert (SVG inline, non cliquable, title au survol) affiché sur un propriétaire QUI A une conversation rattachée, posé sur : carte du pipeline (lc-top), table Propriétaires (cellule nom), ligne de l'onglet Tâches, et en-tête de fiche (à côté du nom). En-tête de fiche : puce verte « ✓ Conversation WhatsApp » quand wa_linked (distincte du lien « 💬 Écrire » = wa.me, qui lui existe dès qu'il y a un numéro). Sens inverse (inbox WhatsApp) : tags renforcés → « 🔗 propriétaire » (vert, + title = nom du propriétaire) vs « ⚠ à trier » (ambre) ; titre du fil préfixé 🔗/⚠. CSS (styles.css) : .wa-mark, .wa-chip-linked, tags .wa-tag-lead/.wa-tag-unknown durcis (fond + gras). Cache-bust ?v=20260813waicon (css+js). Vérifié en live (Playwright, compte admin) : pipeline = 8 cartes avec le logo WhatsApp (Manu, Mabel, Franco, Luis, Sonia…), fiche Manu = logo + puce « ✓ Conversation WhatsApp », inbox = 10 fils « 🔗 propriétaire » / 5 « ⚠ à trier », API wa_linked correcte (single + liste, 10 propriétaires flaggés), 0 erreur console. node --check server/app OK. Aucune migration de données (lecture seule du flag).
  • - 2026-08-13 — WhatsApp : rattachement AUTOMATIQUE et RÉTROACTIF des conversations aux propriétaires. Demande Matt (« que les messages WhatsApp que je reçois soient associés à mes leads, en automatique »). Le rattachement par numéro existait déjà mais était one-shot à la réception : matchLead(number) (suffixe 8 chiffres) ne posait lead_id que sur le message qui arrivait, donc un message reçu avant que le lead (ou son numéro) n'existe restait orphelin « à trier » pour toujours. État constaté : sur 12 numéros de l'inbox, 9 correspondaient à un lead existant mais plusieurs fils étaient à 0 message lié (Sonia, Gloria, Mabel, Franco) ou partiels (Ramon 3/10, Luis 5/15). Correctif (backend seul, server.js) : nouveau helper linkMessagesForLead(leadId, actorId) (+ leadTails(lead)) qui associe au lead tous les messages orphelins (entrants ET sortants, fil complet) dont le numéro correspond à son tel/whatsapp, et pose une activité de traçabilité whatsapp « N message(s) WhatsApp rattaché(s) automatiquement » quand ≥1 est lié. Appelé à : création (POST /api/leads), édition si phone/whatsapp change (PATCH /api/leads/:lid), ingestion pub/formulaire (POST /api/ingest/lead), et dans le webhook après chaque entrant connu (balaye les anciens orphelins du même numéro). Résultat : un message reçu avant l'existence du lead se rattache tout seul dès que le lead apparaît ou que son numéro est saisi. Backfill one-shot exécuté (garde anti-ambiguïté : lie seulement si UN seul lead correspond au numéro) : 29 messages rattachés sur 10 leads (Ramon 7, Luis 10, Juan Zpesini 3, Lizza 3, + Juan Florentín, Mabel, Franco, Sonia, Gloria, Manu 1 chacun) ; 3 numéros restent réellement inconnus (aucun lead) → toujours « à trier ». Backup DB backend/data/moa-properties-crm.db.bak-20260813walink. Testé : node --check OK ; intégration bout-en-bout (webhook entrant d'un numéro neuf → création d'un lead avec ce numéro → le message orphelin est auto-lié, le fil affiche le lead, l'activité de traçabilité est posée) ; données de test supprimées après. Aucune UI touchée (le front résolvait déjà le lead d'un fil depuis les messages lead_id). Réception seule inchangée (on répond depuis le téléphone).
  • - 2026-08-13 — Fix « Session expirée » : session persistante (cookie httpOnly + refresh glissant), portage du correctif déjà fait sur le CRM MOA. Symptôme Matt (capture) : écran de login MOA Properties avec « Session expirée » sous le bouton, alors qu'il ne s'était pas déconnecté. Cause : l'auth ne reposait QUE sur le JWT en localStorage (TTL 30j) ; iOS Safari / PWA efface le stockage script (Apple ITP) après ~7 jours d'inactivité → JWT perdu → 401 → « Session expirée », même si le token était encore valide côté durée. Correctif (identique à moa-crm) : backend/server.js : constantes SESSION_COOKIE='moa_properties_session', TOKEN_TTL_DAYS=180, REFRESH_AFTER_MS=1j ; helpers signSession/readCookie/setSessionCookie/clearSessionCookie/maybeRefreshSession ; auth() lit le Bearer PUIS le cookie httpOnly en repli (survit à l'éviction localStorage) et renouvelle le token à chaque requête si > 1 jour (fenêtre glissante → un utilisateur actif n'est jamais éjecté au TTL) ; /api/login ET /api/password-setup posent le cookie via setSessionCookie ; nouvel endpoint POST /api/logout (efface le cookie) ; cors({ credentials:true }). frontend/js/api.js : fetch(..., credentials:'same-origin') (le cookie part même sans localStorage) + absorbRefresh (met à jour la copie localStorage depuis l'en-tête x-refresh-token) + méthode logout(). frontend/js/app.js : boot() tente /me même sans token localStorage (le cookie authentifie) ; bouton déconnexion appelle API.logout() puis location.reload(). Le mot de passe ne change jamais : c'est bien le même identifiant à chaque fois, on prolonge juste la session (demande explicite de Matt « garde bien le même mot de passe »). Cache-bust ?v=20260813sess. Restart fait, node --check OK (server/app/api), health 200. Testé (token forgé via JWT_SECRET) : (1) /api/me avec cookie seul, sans Bearer (localStorage simulé vide) → 200 + user ; (2) token vieux de 2j → x-refresh-token + Set-Cookie renvoyés (refresh glissant) ; (3) token frais → pas de re-signature ; (4) sans auth → 401. Fichier de test supprimé après coup. NB : le nom de cookie est distinct de moa-crm (moa_properties_session) donc pas de collision entre les deux CRM.
  • - 2026-08-12 (nuit, +tard⁸) — Fix WhatsApp : aucune conversation reçue (webhook loopback injoignable depuis Docker). Symptôme Matt : session +595971612505 affichée « ✓ Connecté » mais inbox vide. Diagnostic : wa_sessions = session de Matt state=open, mais wa_messages = 0 ; Evolution (Docker evolution-api, 127.0.0.1:8080) avait bien le webhook enabled avec events:["MESSAGES_UPSERT"] MAIS l'URL était http://127.0.0.1:8333/... → depuis le conteneur ça vise le conteneur, pas le CRM sur l'hôte → webhooks perdus. moa-crm (qui marche) utilise WA_PUBLIC_BASE=https://crm-moa.panelbay.com. Correctif : backend/.env WA_PUBLIC_BASE http://127.0.0.1:8333 → https://crm-properties.panelbay.com (+ commentaire corrigé) ; service redémarré ; webhook de l'instance mpcrm_8CEBdmjOijNO9nDI re-posé en direct via POST /webhook/set/{instance} vers l'URL publique (pas de rescan QR nécessaire). Vérifié bout-en-bout : POST simulé messages.upsert sur https://crm-properties.panelbay.com/api/whatsapp/webhook/{instance}?token=... → 200 → ligne créée dans wa_messages → message de test supprimé. Les entrants réels arrivent désormais (l'inbox se remplit à partir de maintenant, pas de rejeu de l'historique). Piège documenté en section 7.
  • - 2026-08-12 (nuit, +tard⁷) — Estimation auto « ce que ce prospect peut nous rapporter » (commission mensuelle MOA) affichée dans le pipeline. Demande Matt. Règle métier (déterministe, aucune IA, backend/estimate.js → estimateLeadRevenue(lead)) : 20% du CA du bien, estimation BASSE. Ancrages : 1 ch bien placé = 800$ CA → 160$/mois ; Luque / centre historique = 600$ → 120$/mois. Le CA dépend du nombre de chambres (studio/1/2/3+, détecté depuis unit_type + intake_data/motivation), du placement (prime = Asunción bien placé ; standard = Luque, centre historique, périphérie Gran Asunción, intérieur ; défaut inconnu = standard, bas) et du nombre de biens (tag multi, plusieurs villes, ou mention « varios/plusieurs » → ×N). db.js : colonnes additives est_revenue INTEGER, est_revenue_note TEXT. server.js : helper applyEstimate(row) posé à la création (POST /api/leads), à l'ingestion (POST /api/ingest/lead) et recalculé après chaque PATCH (une fois par édition de fiche) ; le dashboard (pipelineValue, byStage.v, byAgent.pipe) somme désormais est_revenue. Cron quotidien 0 5 * * * → node recompute_estimates.mjs (filet : recalcule tous les leads, log data/estimates_cron.log). Front : carte pipeline affiche ≈ $X/mois (tooltip = détail du calcul) au lieu de expected_revenue ; total par colonne et header « … de commission est. » = somme est_revenue (dataset.rev aligné) ; table Propriétaires idem ; fiche = encart « Peut nous rapporter : ≈ $X/mois » + note (recalculé en direct via la réponse du PATCH dans commitAll) ; KPI dashboard renommé « Revenu mensuel estimé » (sous-titre « commission MOA, pipeline actif »). CSS .est-box/.est-amt/.est-note/.est-line. Cache-bust ?v=20260812estrev. Backfill exécuté (60 leads, pipeline actif estimé $6230/mois). Service redémarré. Vérifié : anchors OK (160/120), PATCH unit_type='2 dorm' → recalcul live 160→220 puis revert, cartes live ≈ $160/mois + tooltip, colonnes $3.4k de commission est., dashboard $6230/mois. node --check server/db/estimate OK. Note : expected_revenue (champ manuel de la fiche) est conservé et indépendant ; l'estimation vit dans est_revenue. Capture workspace/mp-pipeline-revenue.png.
  • - 2026-08-12 (nuit, +tard⁶) — Flux « définir mon mot de passe » par lien à usage unique (invitation membre) + email de Marlon changé. Demande Matt : rattacher le compte Marlon à bnbqualitypro@gmail.com et lui envoyer un lien pour choisir son mot de passe. Le CRM n'avait qu'un /api/login (pas d'invitation/reset). Ajouté : db.js table additive password_tokens (token PK, user_id, expires_at INTEGER epoch ms, used_at, created_at). server.js 2 routes publiques : GET /api/password-setup/:token (valide jeton non-utilisé/non-expiré → {name,email}) et POST /api/password-setup {token,password} (min 8 car., bcrypt, UPDATE users.password_hash, marque le jeton used_at, renvoie un JWT 30j + user → connexion auto). frontend page statique autonome set-password.html + set-password.js (servies par express.static avant le catch-all ; CSP : styles inline OK, script externe same-origin OK) : valide le jeton, saisie + confirmation, POST, stocke le token dans localStorage('moa_crm_token') et redirige vers /. Data : email de Marlon (a9eY161t2qTISYlu, agent) marlon@moaproperties.com → bnbqualitypro@gmail.com (minuscules) ; jeton généré (crypto.randomBytes(32), expire à 7 jours). Service redémarré (systemctl restart moa-properties-crm.service). Vérifié : GET valide → {Marlon, bnbqualitypro@gmail.com}, jeton bidon → 400, set-password.html/.js = 200 en local ET via https://crm-properties.panelbay.com. node --check server.js + db.js OK. Note : mécanisme réutilisable pour tout futur membre (générer une ligne password_tokens + envoyer le lien). Le POST n'a pas été exécuté en test pour ne pas consommer le jeton de Marlon.
  • - 2026-08-12 (nuit, +tard⁵) — Échéance rapide « Aujourd'hui » → « Demain » + dates au format européen JJ/MM/AAAA. Demande Matt (idem CRM MOA). frontend/js/app.js : le chip d'échéance du panneau « Prochaine action » propose « Demain » (quickDate('tomorrow'), +1 jour) au lieu d'« Aujourd'hui » ; quickDate gère la branche tomorrow. Les 3 fonctions de rendu de date (fmtDate activités, fmtDayShort badges/tâches, fmtDay label next-step) passent en numérique européen toLocaleDateString('fr-FR', {day:'2-digit',month:'2-digit',year:'numeric'}) → 12/08/2026. Cache-bust ?v=20260812eudate. Vérifié live (Playwright) : onglet Tâches et pastilles affichent 12/08/2026, chips = Demain / +1 sem. / +1 mois.
  • - 2026-08-12 (nuit, +tard⁴) — Fiche propriétaire en auto-save (plus de bouton « Enregistrer »). Demande Matt (même chose demandée sur le CRM MOA). Front only, fichiers statiques, aucun restart. frontend/js/app.js (openLeadDetail) : ajout d'une infra d'auto-save (buildPatch() = le même patch que l'ancien save() ; commitAll() PATCH /api/leads/:id + Object.assign(l,patch) + renderNextLabel() + indicateur ; scheduleSave(immediate) débounce 600 ms, immédiat pour select/date/tags/villes/assigné, débounce pour la frappe ; closeDetail() flush si pending puis closeModal()+renderView()). Chaque champ f reçoit un listener change(+input pour le texte) ; tags, villes (add/remove), assigné, quick-dates d'échéance déclenchent scheduleSave(true) ; le stade (frise) était déjà auto-persisté. Boutons « Enregistrer » retirés : celui du pied de fiche (remplacé par l'indicateur .autosave-status « Enregistrement automatique » → « Enregistrement… » → « Enregistré ✓ » / « Non enregistré (erreur) », + bouton « Fermer » qui flush et rafraîchit) et celui du panneau « Prochaine action » (les 2 champs s'auto-enregistrent ; « Effacer » conservé ; fonction morte saveNext supprimée). frontend/styles.css : styles .autosave-status. frontend/index.html cache-bust ?v=20260812mpautosave. Périmètre : uniquement la fiche propriétaire (openLeadDetail). Les modales Création de propriétaire (openLeadEditor), RDV (openMeetingEditor) et Bien (openPropertyEditor) gardent leur bouton (création/objet distinct, pas une fiche client). Vérifié en live (Playwright) : pied = Supprimer + Fermer (aucun « Enregistrer »), édition d'un champ → « Enregistré ✓ » + valeur persistée côté serveur (round-trip API), puis restaurée. node --check OK.
  • - 2026-08-12 (nuit, +tard³) — Parité avec le CRM MOA : badge « prochaine action » sur les cartes du pipeline + nouvel onglet « Tâches ». PLUS gros nettoyage de données (next step partout, bascules de stade, 1 suppression). Demande Matt.
  • - 2026-08-12 (nuit, +tard²) — Pipeline : colonne « Visite / évaluation du bien » remplacée par « RDV pris » (entre Intéressé et Projection envoyée). Demande Matt. db.js : dans STAGES, l'entrée {key:'visite',label:'Visite / évaluation du bien'} devient {key:'rdv_pris',label:'RDV pris'} ; PIPELINE_KEYS : visite → rdv_pris (même position). Les colonnes du kanban et le filtre de stade sont pilotés par /api/meta (aucun hardcode front de visite ; le 'visit' du front est un TYPE d'activité, pas un stade, laissé tel quel). Migration data : les 2 leads en stage='visite' (Percy Blacutt, Vitaly) passés en rdv_pris + note d'activité de traçabilité. Service redémarré. Vérifié : /api/meta pipeline = sans_contact > contacte > interesse > rdv_pris > proposition > gagne (plus de visite), kanban live rendu avec la colonne « RDV pris » (2 leads), 0 erreur console (capture workspace/mp-pipeline-rdv.png).
  • - 2026-08-12 (nuit, +tard) — Nettoyage des fiches : séparation données pub / notes manuelles dans intake_data. Matt avait ajouté à la main des notes en bas du champ intake_data (rempli auto par la pub Meta). Script one-shot : pour chaque lead, chaque ligne classée auto (Q&A du formulaire finissant par ?:, ou métadonnées email/full_name/phone_number/Source/form_id/leadgen_id/created_time) vs note (tout le reste). Les lignes note sont déplacées dans le champ notes (commentaires manuels) et retirées de intake_data (qui ne garde que la data pub propre ; blancs multiples nettoyés). 18 leads concernés (ex. Diego Galeano « le président, envoyer proposition meubles », Marcela Sosa « rdv jeudi 13h », Percy Blacutt « Insignia 6, envoyer analyse »…), 43 n'avaient que de la data pub. Backup complet avant modif : backend/data/intake_notes_backup_2026-08-12.json. Vérifié : 0 note résiduelle dans intake_data, notes multi-lignes préservées. Livré aussi : proposition de next step par lead (workspace/moa-properties-next-steps-2026-08-12.md, 61 leads, tiers RDV/chauds/à qualifier/hors-zone) + anomalies signalées (3 leads « Gagné » erronés : Margarita Ivaldi à écarter, Sicilia Villalba & Gabriel Navarro pas signés ; ~11 leads « otro ciudad » encore dans le pipeline actif à basculer en réserve Hors Asunción).
  • - 2026-08-12 (nuit) — Ville(s) du bien (multi-sélection) + réserve « Hors Asunción » + nettoyage du champ « Type de bien ». Demande de Matt : les propriétaires dont le bien est hors Asunción étaient bricolés en écrivant le nom de la ville dans « Type de bien » (unit_type) ; il voulait (1) un vrai champ ville multi-valué, (2) un statut pour mettre ces leads en réserve (comme « Marquer perdu ») consultable plus tard si MOA Properties ouvre d'autres villes, (3) le nettoyage/migration des fiches existantes. Livré :
  • - 2026-08-12 (soir, +tard) — WhatsApp : inbox PARTAGÉE (numéro pro commun) au lieu de cloisonnée par utilisateur. Avant : chaque compte ne voyait que les messages de SA propre instance (wa_messages.user_id), donc un gestionnaire ne voyait pas la ligne appairée par un autre. Le numéro pro étant commun aux gestionnaires, la lecture est désormais mutualisée entre tous les comptes autorisés WhatsApp (les 2 admins + Marlon). Détail :
  • - 2026-08-12 (soir) — Fiche prospect alignée sur moa-crm + champ dédié intake_data pour les infos pub/formulaire. Design/organisation copiés du CRM de vente (plus abouti) :
  • - 2026-08-12 — Socle RDV (rendez-vous propriétaires) : table meetings + formulaire de booking + onglet Rendez-vous. AUCUNE automatisation à ce stade (pas d'agenda, pas de Meet, pas de webhook, pas de rappels : saisie propre + affichage uniquement, à valider avant de brancher les autos une par une). Contexte : le RDV est un rendez-vous commercial pour signer un PROPRIÉTAIRE en gestion (conciergerie courte durée), pas une vente d'investissement. Détail :
  • - 2026-08-07 — Intégration WhatsApp (appairage QR + inbox réception) portée depuis moa-crm. Nouveau module backend/wa_evolution.js (client Evolution API, instances mpcrm_<userId>), tables wa_sessions + wa_messages (migration additive dans db.js), et endpoints GET /api/whatsapp/status, POST /api/whatsapp/connect (renvoie le QR base64), POST /api/whatsapp/logout, POST /api/whatsapp/webhook/:instance (public, gardé par ?token=, réception seule), GET /api/whatsapp/inbox, GET /api/whatsapp/thread, POST /api/whatsapp/thread/create-lead. Front : onglet WhatsApp dans la nav (renderWhatsapp + inbox/fil dans app.js, méthodes whatsapp* dans api.js, CSS .wa-* dans styles.css). Config backend/.env : bloc WhatsApp ajouté (même serveur Evolution 127.0.0.1:8080 et même EVOLUTION_API_KEY que moa-crm ; WA_ALLOWED_EMAILS=marlon@moaproperties.com ; webhook en loopback WA_PUBLIC_BASE=http://127.0.0.1:8333 + WA_WEBHOOK_SECRET propre). Réception SEULE (aucun envoi depuis le CRM, choix anti-ban). Un message d'un numéro connu est rattaché au propriétaire (activité whatsapp) ; un numéro inconnu reste "à trier" et peut être converti en propriétaire depuis le fil. Différence vs moa-crm : pas de colonne intake_source ici (retirée de l'INSERT), et pas d'assistant IA (non porté). Vérifié end-to-end : instance créée, QR réel généré et affiché dans l'UI, webhook enregistré côté Evolution, message entrant simulé capté dans l'inbox, garde 403 sur mauvais token, accès réservé admins + Marlon. Reste côté humain : l'utilisateur (Marlon ou un admin) scanne le QR depuis l'onglet WhatsApp avec le téléphone portant le numéro business MOA Properties pour finaliser l'appairage.
  • - 2026-08-02 — Manuel initialisé (bootstrap auto).
  • CHANGELOG

  • - 2026-09-10 : Séquence de relance scriptée 7 jours pour les non-répondeurs (nouvelle brique, envoi décalé). Demande Matt : les 5 leads Meta contactés (1 opener) mais jamais répondu échappaient au moteur de relance IA (silent-threads n'expose que les fils où le proprio a DÉJÀ écrit). CODE : (1) db.js : nouveau stade terminal sans_respuesta (« Sans réponse (relance épuisée) ») + ajout à CLOSED_STAGES — archivage des proprios relancés 7 jours sans réponse. (2) wa_evolution.js : sendAudio(name, number, {audio}) → /message/sendWhatsAppAudio (vraie NOTE VOCALE PTT, pas un fichier joint). (3) server.js : waSendMediaReply gère audio/* (mediatype audio via sendAudio, marqueur « 🎙️ Vocal ») ; 2 nouveaux endpoints internes POST /api/ai/relance-send (texte) et POST /api/ai/relance-send-media (image/audio, binaire brut) sous agentAuth (jeton), qui appellent waSendReply/waSendMediaReply SANS le garde-fou hasInbound des endpoints /reply — contournement volontaire et étroit, réservé à la séquence (décision explicite de Matt : ces leads ont bien reçu l'opener). (4) backend/relance_sequence.py (moteur : état JSON data/relance_sequence_state.json, commandes init/run/status/--dry-run) + run_relance_sequence.sh (**cron 30 11 * * *, log /root/aios/logs/mp-relance-sequence.log). Séquence (contenu FIGÉ avec Matt, voseo, zéro ¿/¡) : J1 texte · J2 image (workspace/generated-images/moa-properties-relance-D1.png) + légende · J3 texte · J4 texte · J5 vraie voix de Matt (workspace/generated-audio/mp-relance-vocal-D3.ogg) + petit texte · J6 texte · J7 permission/ultimatum (style MOA Academy). Anti-spam/anti-ban : ordre mélangé + jitter aléatoire entre envois (jamais de blast simultané), 1 msg/personne/jour, pas de rattrapage groupé ; dès qu'un proprio RÉPOND (entrant détecté), la séquence s'arrête pour lui (statut replied, repart en flux normal) ; après J7 sans réponse → sans_respuesta. node --check (db/wa_evolution/server) + py_compile OK, restart fait, endpoints testés (401 sans jeton). J1 envoyé live aux 5 le 2026-09-10 (Marina, Sabina, Nestor, Suse, Julio ; horodaté en décalé, fiches passées en « Contacté »). NB à surveiller : le chemin image réutilise waSendMediaReply déjà éprouvé, mais le chemin audio PTT** (sendWhatsAppAudio) est NEUF et non testé en envoi réel — à valider avant que J5 ne parte (4 jours). Ligne d'envoi = instance de Matt mpcrm_8CEBdmjOijNO9nDI (numéro 595971612505, seule connectée), la même que les openers.
  • - 2026-09-06 : Fix boucle de skip de l'agent brouillons (~80% de la conso claude de l'instance) + agents passés en Sonnet. Audit conso tokens de la semaine (demande Matt) : ai_draft_agent.py (cron */3) réévaluait les MÊMES fils clos à chaque passe car une décision "ne pas répondre" n'était enregistrée nulle part (le dédup shadow ne couvrait que les brouillons produits) → ~3800 appels claude/jour en Opus pour conclure ~5x "skip" toutes les 3 min, 24h/24 (34M tokens de sortie sur la semaine du 30/08, limites de session claude.ai atteintes le 06/09 à 10h30, cf. log). CODE : (1) server.js : nouvel endpoint POST /api/ai/skip (agentAuth, idempotent) qui insère une ligne ai_drafts status='skipped' liée au trigger_msg_id ; GET /api/ai/pending-threads exclut désormais les fils dont le dernier entrant a un shadow OU un skipped (un nouveau message entrant rouvre le fil naturellement). (2) ai_draft_agent.py : la branche [skip] poste la décision + raison sur /api/ai/skip. (3) Modèle : les 5 agents claude du CRM (ai_draft, decision, confirm_rdv, pause_review, relance) passent de Opus (défaut) à --model sonnet (qualité suffisante pour rédaction/jugement WhatsApp, quota bien moindre). node --check + py_compile OK, restart fait. TESTÉ live : 1er passage = 4 skips enregistrés en base + 1 handover réel (El Padrino → pause IA + alerte Marlon, comportement nominal) ; 2e passage = « Fils en attente : 0 », zéro appel claude. Effet attendu : appels seulement sur du VRAI nouveau trafic entrant (dizaines/jour au lieu de ~3800). NB : les lignes skipped s'accumulent en base (inertes, quelques dizaines/jour max) ; purge possible plus tard si besoin.
  • - 2026-09-01 : Fix garde-fou "argent" trop large + nouvelles règles de style IA. Demande Matt (revue quotidienne des conversations en attente). (1) server.js AUTO_FORBIDDEN_RE : ne bloque plus l'auto-envoi sur le simple MOT "estimación"/"precio"/"tarifa"/"cobramos" seul (vocabulaire normal de qualification, bloquait ~50% de la file pour rien, cf. Diego/Liz revenus 2x dans la file le 01/09) ; ne bloque désormais QUE s'il y a un vrai chiffre/% /montant à proximité (commission+chiffre, %, $/gs/₲+chiffre). Testé en isolation (node -e) sur les cas réels avant/après. node -c OK, restart fait. (2) ai_draft_agent.py + relance_agent.py (WORKFLOW) : interdiction explicite du « ¡ » ouvrant (en plus du « ¿ » déjà banni) + strip défensif côté code (.replace("¡","") en miroir du .replace("¿","") existant) ; nouvelle règle "calque ta longueur sur celle du propriétaire" (pas de pavé en réponse à un message de 2 mots). Prend effet au prochain passage cron (pas de restart nécessaire, scripts lancés à chaque fois). (3) Question ouverte avec Matt (pas encore tranchée) : la règle "mini-projection teasing puis relance pour un call" existe déjà en partie (qualif → annonce de l'estimation → RDV seulement après), mais le CHIFFRE lui-même ne part JAMAIS dans le chat auto (design initial "aucun montant sans validation Matt"). À clarifier : le proprio doit-il recevoir le chiffre bas (teaser) automatiquement une fois qualifié, ou ça reste un envoi validé par Matt (projection_gate_agent.py) ?
  • - 2026-08-27 : Rappel automatique de RDV (~3h avant), indépendant du mode IA. Demande de Matt (message validé). Tout RDV confirmé (meetings.status='scheduled') déclenche UN rappel WhatsApp au propriétaire ~3h avant l'heure, même si le mode IA auto est sur off : c'est un job transactionnel séparé de l'agent de réponse, il ne passe PAS par la file d'approbation. CODE : (1) db.js migration additive ALTER TABLE meetings ADD COLUMN reminder_sent_at TEXT (anti-doublon, un seul rappel par RDV). (2) server.js, juste avant app.listen : runRdvReminders() sur setInterval de 5 min (+ un passage à T+20s au boot). Sélectionne les RDV scheduled sans reminder_sent_at, calcule l'instant UTC réel via rdvZonedToUtc(starts_at, zone) (mapping RDV_TZ py/fr/ch/be/ca → IANA, lit la vraie base de fuseaux : Paraguay = UTC-3), n'envoie que si le RDV tombe dans les 3h à venir (delta ∈ ]0, 3h]) et si une heure précise est saisie (date seule → ignorée). Envoi via waSendReply (même ligne Evolution que le reste du CRM, journalise le sortant + une activité « Rappel de RDV automatique envoyé »), puis reminder_sent_at horodaté. GARDE-FOU : skip uniquement si pas de numéro (whatsapp||phone). Le rappel part dans TOUS les cas, y compris sur les leads en mode Manuel (ia_off) : c'est une notification logistique (RDV déjà confirmé), pas une conversation IA ; ia_off ne coupe que l'IA conversationnelle (réponses/relances). [correction du même jour, à la demande de Matt : le garde-fou ia_off initial a été retiré.] LANGUE : ES par défaut, FR si le lead est francophone (leads.language). Message validé : « ¡Hola {prénom}! 👋 Te escribo de MOA Properties para recordarte nuestra reunión de hoy a las {hora} ({fuso}). ¡Te esperamos! 🙌 » (équiv. FR). node --check OK (server.js + db.js), restart fait, migration vérifiée (colonne présente), conversion de fuseau testée sur le RDV existant (15:00 Asunción = 18:00 UTC, rappel prévu à 15:00 UTC = 3h avant). NB anti-ban : l'envoi passe par Evolution (comme les réponses manuelles/IA du CRM) ; les prospects avec RDV ont déjà une conversation active, risque minime. L'idée « rappels via l'API Meta officielle » évoquée au 2026-08-12 (AUTO B) reste une option future si on veut relancer des numéros froids.
  • - 2026-08-26 (4) : Auto-passage "sans contact -> contacté" au 1er message + rangement des fiches. Demande Matt (24 leads en "à contacter" dont la plupart déjà contactés). (1) CODE : dans waSendReply (server.js, le chemin d'envoi commun au manuel ET à l'IA), dès qu'un sortant part vers un lead en sans_contact, il passe automatiquement en contacte (+ stage_changed_at mis à jour + activité "Premier message envoyé"). Résultat : un lead ne reste plus jamais en "à contacter" après un premier message. (2) DONNÉES : migration one-shot, 17 leads sans_contact ayant déjà un sortant WhatsApp rerangés en contacte, avec stage_changed_at daté du 1er envoi réel (pas "aujourd'hui"). Backup data/moa-properties-crm.db.bak-20260826-recontact. Restant en sans_contact : 7 (aucun message dans le CRM, tous Meta Lead Ads, jamais écrits via le WhatsApp du CRM). node --check OK, restart fait.
  • - 2026-08-26 (3) : Conversation WhatsApp accessible en 1 clic (fiche + À approuver). Demande Matt. (1) Dans la fiche prospect (openLeadDetail, colonne droite) : nouvelle section « 💬 Conversation WhatsApp » qui charge et affiche le fil WhatsApp du proprio (bulles in/out) via API.whatsappThread(l.whatsapp||l.phone). (2) Onglet « À approuver » : le nom du prospect est cliquable + bouton « 💬 Voir toute la conversation » sur chaque carte → ouvre la fiche complète (openLeadDetail) si lead rattaché, sinon une modale de conversation (openWaThreadModal) pour un numéro non rattaché. Helper commun fillWaThread(container, number) (réutilise le markup .wa-msg/.wa-msg-body/.wa-msg-time de l'inbox). CSS .fiche-wa/.appr-open/.appr-seeall. Accès gardé par requireWa (admins + Marlon) : pour un compte sans accès WA la section affiche « Accès réservé ». Cache-bust ?v=20260826waconv. node --check OK, frontend statique (pas de restart). NB : ouvrir le fil marque les entrants comme lus (comportement voulu).
  • - 2026-08-26 (2) : Vue Pipeline : filtre Tous / IA auto / Manuel + désactivation du sélecteur d'agent. Demande Matt. (1) Nouveau filtre segmenté en tête du pipeline (renderPipeline, app.js) : Tous / 🔴 IA auto / 🟢 Manuel, piloté par state.pipelineView ('all'|'auto'|'manual'), qui filtre les cartes sur le tag ia_off (via isIaManual, cohérent avec la pastille et le toggle de fiche). CSS .pipe-filter/.pf-opt (styles.css). (2) La fonctionnalité « Voir le pipeline de <agent> » (agentPicker) est désactivée, pas supprimée : encadrée par le flag PIPELINE_AGENT_PICKER = false dans renderPipeline (remettre à true pour réactiver) ; state.pipelineAgent forcé à null tant que désactivé. Cache-bust ?v=20260826pipefilter. node --check OK, frontend statique (pas de restart). NB : filtrage 100% côté client sur les leads déjà chargés.
  • - 2026-08-26 : Pilotage IA/Manuel visible + passation sur intérêt + mode écoute anti-coupure (3 demandes de Matt). (1) Pastilles pipeline : chaque carte du pipeline porte une pastille 🔴 IA (discussion auto) ou 🟢 Manuel (repris à la main), basée sur le tag ia_off (source de vérité, cohérente avec l'étiquette WhatsApp). app.js : helpers isIaManual() + iaPastille(), injectés dans leadCard ; CSS .ia-pastille/.ia-auto/.ia-manual dans styles.css. (2) Toggle sur la fiche : bouton Discussion : IA auto / Manuel dans l'en-tête de openLeadDetail, bascule instantanée (ajoute/retire ia_off via l'auto-save existant commitAll qui envoie tags), synchronisé dans les deux sens avec le picker de tags ; CSS .ia-toggle. (3) Passation sur intérêt : nouvel endpoint POST /api/ai/handover (agentAuth) + helper alertMarlon() (server.js). L'agent ai_draft_agent.py a un champ handover/handover_reason dans son contrat JSON : quand le proprio est jugé CHAUD (donne plein d'infos, veut avancer, demande à parler à qqn), il appelle /api/ai/handover au lieu de rédiger → IA en pause (ia_off), drafts annulés, étiquette WhatsApp → Marlon, alerte WhatsApp à Marlon (MARLON_WA_NUMBER .env, filet notif AIOS). PAS un compteur de messages : décision d'intérêt par l'IA (demande explicite de Matt). (4) Mode écoute anti-coupure : dans GET /api/ai/pending-threads, si le proprio enchaîne une salve de messages à intervalles courts (≥2 entrants rapprochés, dernier < MP_LISTEN_QUIET_S=75s, écart < MP_LISTEN_CLUSTER_S=180s), le fil est mis en attente ce tour-ci : l'IA le laisse finir et répond à toute la salve au passage suivant. Un message isolé n'est jamais retardé. Nouvelles clés .env : MARLON_WA_NUMBER (vide par défaut = alerte via notif AIOS seule), MP_LISTEN_QUIET_S, MP_LISTEN_CLUSTER_S. Restart fait, node --check + py_compile OK, endpoint testé (401 sans token, skipped:no_lead sur numéro bidon, pending-threads 200). Reste côté humain : donner à l'AIOS le numéro WhatsApp de Marlon pour renseigner MARLON_WA_NUMBER (sinon l'alerte tombe sur la notif AIOS de Matt, pas sur le WhatsApp de Marlon).
  • - 2026-08-14 : Rattrapage : 5 contacts WhatsApp non enregistrés créés en fiches (données, pas de code). Audit des conversations WhatsApp : 5 numéros discutaient avec MOA Properties sans exister comme lead (donc invisibles au pipeline). Créés en base (INSERT direct, DB en WAL, backup data/moa-properties-crm.db.bak-20260814-103925 avant écriture ; id nanoid 16 car. via l'alphabet de db.js), et surtout leurs messages WhatsApp existants ont été rattachés à la fiche (UPDATE wa_messages SET lead_id=...), pour qu'on puisse écrire depuis le CRM et suivre l'échange. Détail : Júlio (+555196944471, stage interesse + tag hot, prospect appart 80 m2, 11 msgs reliés) ; Dario (+595982130783, gagne, proprio en onboarding actif, WA affiche « Fonoelectro », 20 msgs) ; Emily Rivas (+595971203202, gagne, virement en cours, 7 msgs) ; Dubeylis Rivas (+595972442922, gagne, virement via sa sœur, lien possible avec Emily, 8 msgs) ; Mathis Delettre (+595991684206, sans_contact + tag Agent MOA, créé comme contact et non prospect à la demande de Matt, 2 msgs). Après opération : 0 message entrant orphelin, 73 → 78 leads. À valider par Matt : les stades gagne de Dario/Emily/Dubeylis sont une déduction (relation active/paiement), à corriger si le mandat n'est pas réellement signé ; Mathis reste en colonne « Sans contact » faute de stade « contact/agent » dédié (filtrable par le tag Agent MOA).
  • - 2026-08-13 : Pièces jointes WhatsApp (envoi de fichiers dans une conversation ouverte). Demande de Matt. En plus du texte, un bouton 📎 dans la barre de saisie du fil permet d'envoyer une image / vidéo / document au propriétaire. wa_evolution.js : sendMedia(name, number, {mediatype, mimetype, media(base64), fileName, caption}) → /message/sendMedia d'Evolution. server.js : waSendMediaReply() (même sélection d'instance ouverte que waSendReply, mediatype déduit du MIME : image/* → image, video/* → video, sinon document ; journalise un sortant avec marqueur lisible « 📷 Photo » / « 🎥 Vidéo » / « 📎 <nom> » + légende, et une activité sur la fiche) ; endpoint POST /api/whatsapp/thread/reply-media (auth+requireWa) qui lit le fichier en binaire brut via express.raw({type:()=>true, limit:'16mb'}) (évite le gonflement base64 ET la limite JSON globale de 2 Mo), métadonnées en query (number, fileName, caption), MIME = Content-Type. Même garde-fou anti-ban que /reply : 409 si le numéro n'a jamais écrit. FRONT (app.js, openThread) : bouton 📎 + <input type=file> caché (accept image/vidéo/pdf/office, max 16 Mo), le texte de la zone de saisie sert de légende ; api.js whatsappReplyMedia(number, file, caption) (POST binaire brut, mêmes bearer/cookie). CSS .wa-compose-attach. Cache-bust ?v=20260813attach. Restart fait, node --check OK. Testé (JWT admin forgé) : numéro froid → 409, corps vide → 400, sans auth → 401 (la route lit bien le binaire, non bloquée par la limite JSON). L'envoi réel réutilise la sélection d'instance de waSendReply (éprouvée) ; le fichier réel part sur WhatsApp, le fil CRM affiche le marqueur (comme les médias entrants « [image] », pas d'aperçu du fichier stocké côté CRM en v1).
  • - 2026-08-13 : Automatisations de réponse — Phase 2 : apprentissage sur édition. Demande de Matt (« continue les automatisations de réponse »). Avant : l'IA n'apprenait que du rejet-avec-consigne ou du bouton « 🎓 Apprendre » ; quand un gestionnaire ÉDITAIT un brouillon avant de l'envoyer, la correction était perdue. Maintenant chaque édition envoyée devient un exemple d'apprentissage. DB (db.js) : 3 colonnes idempotentes sur ai_feedback — kind (guidance|edit), ai_text (ce que l'IA avait proposé), human_text (ce qui est réellement parti). BACKEND (server.js) : à l'POST /api/ai/drafts/:did/send, si le texte envoyé diffère du brouillon (comparaison normalisée : casse/espaces/ponctuation finale ignorées), on insère un feedback kind='edit' (ai_text + human_text + situation = message déclencheur) ; best-effort, ne bloque jamais l'envoi ; la réponse porte learned:true. GET /api/ai/feedback renvoie désormais kind/ai_text/human_text (limite 60). AGENTS (ai_draft_agent.py + relance_agent.py, build_prompt) : le feedback est scindé en deux blocs — « CONSIGNES APPRISES » (guidance explicite, ≤15) et un nouveau bloc few-shot « CORRECTIONS PASSÉES (tu avais écrit A, le gestionnaire a envoyé B — imite B) » (≤8 exemples), pour que l'IA imite le style réellement validé. FRONT (app.js) : le toast d'envoi affiche « Envoyé ✓ · correction apprise 🎓 » quand une édition a été captée. Cache-bust ?v=20260813learn. Restart fait, py_compile + node --check OK. Testé : (a) migration → 3 colonnes présentes ; (b) insertion d'un feedback edit jetable → exposé par /api/ai/feedback avec kind/ai_text/human_text (nettoyé après) ; (c) build_prompt (harnais Python) → rend bien les 2 blocs, exemples A→B inclus. NB : l'envoi réel (donc la capture live) nécessite une ligne WhatsApp ouverte, non simulable ici ; la logique de capture est branchée sur le chemin d'envoi existant (waSendReply, déjà éprouvé). Boucle complète : l'IA rédige → tu édites/approuves → ta correction nourrit les prochaines rédactions.
  • - 2026-08-13 : Envoi WhatsApp manuel dans une conversation ouverte + nouveau stade « RDV terminé ». Deux demandes de Matt. (1) Répondre depuis le CRM : le module WhatsApp était en réception seule (seul l'envoi via la file d'approbation IA existait). Nouvel endpoint POST /api/whatsapp/thread/reply (server.js, auth+requireWa, body {number,text}) : réutilise waSendReply (choix d'instance ouverte + journalisation du sortant + activité sur la fiche). Garde-fou anti-ban : refuse (409) si le numéro n'a JAMAIS écrit dans la portée du gestionnaire (hasInbound = ≥1 message direction='in'), le premier contact reste manuel depuis le téléphone ; 400 si texte vide ou >4000 car. FRONT (app.js, openThread) : zone de saisie (textarea auto-grow + bouton ➤) affichée seulement si ligne PROPRE (pas la vue « ligne partagée » lecture seule) ET conversation déjà ouverte (d.messages.some(in)), sinon hint explicatif ; Entrée = envoyer, Maj+Entrée = nouvelle ligne, brouillon préservé entre les refresh du poll. api.js : whatsappReply. CSS .wa-compose* (styles.css). Cache-bust styles/api/app ?v=20260813wasend. Restart fait, node --check OK. Testé (JWT admin forgé) : numéro froid → 409 (message anti-ban), texte vide → 400, sans auth → 401. L'envoi réel passe par waSendReply, déjà éprouvé par la file d'approbation IA. (2) Stade « RDV terminé » ajouté au pipeline entre « RDV pris » et « Projection envoyée » (db.js : STAGES + PIPELINE_KEYS, clé rdv_termine). Le front lit les stades depuis /api/meta, donc la colonne kanban, le filtre et le sélecteur d'étape l'affichent automatiquement (zéro changement front, aucune migration : stage est un TEXT). Vérifié live via /api/meta.
  • - 2026-08-07 : Branchement des leads Meta (pub MOA Properties) sur l'ingest. Le formulaire Facebook "Properties" (id 1976951876332661) est désormais routé vers ce CRM via POST /api/ingest/lead (source Meta Lead Ads, dédup sur external_ref = leadgen_id). Côté connecteur (/root/aios/lib/meta_leads/, cron */3), ce même formulaire notifie AUSSI l'associé sur Telegram : les deux actions se cumulent (META_MP_FORM_IDS + META_TG_FORM_IDS = ce form). Backfill des 30 leads historiques poussés dans le CRM (fiches "Sans contact", non assignées). Aucune modif de code de cette app (l'endpoint ingest existait déjà) ; changement = activation côté connecteur. À faire côté humain : assigner ces 30 fiches à un responsable et démarrer le suivi. Détails de continuité (token System User permanent, alerte anti-panne silencieuse du poller) : voir /root/aios/lib/meta_leads/.
  • - 2026-08-04 : migration vers panelbay.com. Nouvelle URL https://crm-properties.panelbay.com (HTTPS Cloudflare + Let's Encrypt). L'ancienne https://crm-properties.bouthors-m.tenga.run reste active en parallele (double-service) le temps de la transition.
  • - 2026-08-05 : corrections d'audit (P1/P2 + a11y). Sécurité : retrait des identifiants préremplis et du hint mot de passe sur la page de login ; JWT_SECRET déplacé dans .env (valeur conservée, WARN si absent) ; contrôle d'accès par rôle (suppressions + réassignation réservées aux admin, côté serveur et masquées côté front) ; ajout de Helmet + CSP, x-powered-by désactivé, CORS restreint (CORS_ORIGINS). Fondations : pagination limit/offset + shaping groupé (fin du N+1) sur /api/leads et /api/properties ; validation/coercition serveur des champs numériques et email (leads, properties, ingest). Correctness : matching biens réparé (score zone/type sur biens status='actif'). Accessibilité/UI : nav mobile (sidebar horizontale sous 720px), focus clavier visible (:focus-visible), nav en <button>, cartes/lignes cliquables au clavier (role/tabindex/Entrée-Espace), labels for/id, modale role=dialog + Échap + piège de focus, colonnes numériques tabular-nums/alignées/zebra, prefers-reduced-motion. Dépendance ajoutée : helmet. Sauvegarde DB : backend/data/*.bak-20260805.
  • - 2026-08-25 : service tombé (arrêt propre en masse à 18h37 le 24/08, non rattrapé par Restart=on-failure). Relancé + policy passée à Restart=always pour éviter la rechute.
  • - 2026-09-14 : Liste noire par numéro (ne jamais répondre à un numéro donné). Demande de Matt : ne pas répondre au numéro finissant par 840 (595994133840, "Gabriel Echauri", un promoteur cherchant des brokers, hors cible conciergerie). Le tag ia_off ne coupe l'IA que pour un fil RATTACHÉ à une fiche ; or ce numéro n'a pas de lead, donc il passait au travers de /api/ai/pending-threads (l'IA lui avait déjà rédigé un brouillon pending). Nouveau coupe-circuit robuste par numéro : helper waBlocked(num) (server.js, près de getSetting) qui lit le réglage app_settings.wa_blocklist (numéros normalisés séparés par des virgules, relu à chaque requête, aucun redémarrage pour ajouter/retirer). Appliqué à 3 endroits : /api/ai/pending-threads (skip), /api/ai/silent-threads (pas de relance) et POST /api/ai/drafts (refuse brouillon/auto-envoi, {ok:true, blocked:true}). Données : wa_blocklist='595994133840' posé, et le brouillon en attente MUy0eGrlLnRhYgZS passé en rejected (decided_by blocklist_matt). node -c OK, service redémarré (200). Pour bloquer un autre numéro : ajouter ses chiffres à wa_blocklist dans app_settings (virgule).