← Tous les manuels · Portail
↗ Ouvrir l'app

MANUAL — moa-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-03 · Statut : brouillon auto (à relire)

1. Identité

  • - Rôle : CRM de vente immobilière MOA Real Estate (prospects + pipeline figé façon Henrique Portella), pour les agents MOA. La section Biens est le catalogue des unités promoteurs (dispos à la vente), lecture seule depuis moa-catalog. Ne PAS confondre avec le CRM MOA Properties (conciergerie, moa-properties-crm).
  • - URL publique : https://crm-moa.panelbay.com
  • - Entité : MOA (Real Estate)
  • - Login admin : matt@moarealestate.com / moacrm2026. Agents = prenomnom@moa-realestate.com / MoaCRM2026! (temporaire, changé à la 1re connexion). Récap complet : /root/workspace/moa-crm-acces-agents.md.
  • - Rôles : admin, coordinateur (Gary), agent, assistant (Valentina, valentina@moa-realestate.com), finance (Giannina, giannina@moa-realestate.com). Le rôle assistant n'a accès qu'à un seul onglet, Promoteurs, où il édite le tableau des conditions commerciales (promoter_terms). Aucun autre onglet, aucune autre donnée (dashboard, leads, ventes, WhatsApp... tous 403 côté serveur). Détail : voir CHANGELOG 2026-08-27. Le rôle finance (responsable financière) n'a accès qu'à un seul onglet, Ventes (board renderVentesMoa de Gary, source moa-ops), en LECTURE SEULE : voit toutes les ventes + le détail de prix, mais ne change aucun statut, n'édite aucune fiche, pas de leads à assigner, ne reçoit pas les emails de vente (tout POST/PATCH/DELETE = 403 côté serveur). Détail : voir CHANGELOG 2026-09-21. Le rôle coordinateur (Gary) a la Coordination (Ventes + Mes prospects) mais ne voit QUE les leads qu'il a lui-même saisis (created_by), éditables via "Mes prospects" : pas d'onglet "Leads à assigner", pas de file/recherche globale (GET /api/leads/queue = requireAdmin ; GET /api/leads/search gardé pour le contrôle de doublon à la création). Détail : voir CHANGELOG 2026-09-22 (retrait onglet Leads). Nommage (règle Matt) : "Giannina" = la responsable financière (rôle finance) ; "Gia" = l'agente Gia Ferreira (contact.giafferreira@gmail.com, rôle agent). Ne jamais confondre les deux.
  • 2. Exécution / infra

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

  • - backend/server.js — routes Express (auth JWT, leads, pipeline, properties, dashboard, catalogue).
  • - backend/db.js — schéma SQLite + seed (better-sqlite3). Table properties = biens "curés" liables aux prospects (démo pour l'instant).
  • - backend/catalog.js — pont lecture seule vers moa-catalog/catalog.db (catalogue promoteurs). N'écrit jamais. Sert aussi les documents de moa-catalog/docs/.
  • - frontend/js/app.js — UI (vue Biens = drilldown catalogue), frontend/js/api.js, frontend/styles.css.
  • - PWA (installable sur bureau/mobile) : frontend/manifest.webmanifest (nom, icônes, display:standalone, couleurs marque), frontend/sw.js (service worker, network-first, ne touche jamais /api/*), frontend/js/pwa.js (bouton flottant "Installer l'app" + invite native beforeinstallprompt + modale d'instructions iOS/Safari/Firefox, i18n ES/FR/EN), frontend/icons/ (192/512/maskable/apple-touch/favicon, générées PIL). CSP : le script est un fichier propre (pas d'inline, scriptSrc n'a pas 'unsafe-inline').
  • 4. Données

  • - Base CRM : backend/data/moa-crm.db (users, leads, activities, properties, lead_properties).
  • - Catalogue (source externe, lecture seule) : /root/workspace/moa-catalog/catalog.db — tables promoteurs / projets / unites. Alimenté et rafraîchi par les crons de moa-catalog (prix 1h30, docs dimanche 2h ; horaires corrigés le 2026-09-01, voir moa-catalog/MANUAL.md). Le CRM le LIT uniquement (source de vérité unique, toujours à jour). 19 promoteurs / 141 projets / ~7000 unités au 2026-09-01.
  • - Documents catalogue : fichiers sous /root/workspace/moa-catalog/docs/<promoteur>/… (brochures, plans, matériaux), servis via /api/catalog/doc.
  • 5. API / endpoints

  • - GET /api/catalog/promoters — promoteurs + compteurs (projets, unités, libres, docs).
  • - GET /api/catalog/promoters/:id/projects — projets d'un promoteur (dispos, fourchette de prix, devise) + liste des documents.
  • - GET /api/catalog/projects/:id/units — unités d'un projet (libres d'abord ; ?all=1 inclut réservées/vendues). Renvoie aussi docsByType (documents du projet groupés par type) + docsScope (project si le drive est rangé par projet, promoter si docs communs au promoteur).
  • - GET /api/catalog/units/search?minUsd&maxUsd&m2min&promoterId&hab&q&all&limit — recherche multi-critères sur TOUTES les unités, tous promoteurs. Prix normalisés en USD (prix_usd) pour filtrer/trier cross-devise (taux PYG_PER_USD en dur dans catalog.js, ~7300). hab = nb de chambres (0 studio/mono, 1, 2, 3 = 3+). Renvoie {rate, total, units} (plafonné à limit, défaut 400).
  • - GET /api/catalog/brochures — toutes les brochures/présentations (PDF/DOCX/PPTX classés type brochure), tous promoteurs, pour l'onglet Brochures.
  • - GET /api/catalog/map — projets géolocalisés pour la vue carte : {rate, generated, total, located, mapboxToken, projects:[{proj_id, prom_id, projet, promoteur, zona, n_unites, n_libres, prix_min_usd, prix_max_usd, lat, lng, confidence, address}]}. Coordonnées jointes depuis backend/data/project_geo.json (voir §4). lat/lng=null = projet non localisé.
  • - GET /api/catalog/doc?t=<jwt>&path=<rel> — télécharge un document (sandboxé à moa-catalog/docs/, token en query pour ouverture en nouvel onglet).
  • - DELETE /api/leads/:lid
  • - DELETE /api/leads/:lid/properties/:pid
  • - DELETE /api/properties/:pid
  • - GET /api/dashboard
  • - GET /api/dashboard/weekly-sales?weekOffset=N — admin : ventes NOUVELLES d'une semaine (fiches ventas créées, created_at, heure locale Asuncion). weekOffset 0=semaine en cours, -1=précédente (défaut). Vision "Briefing hebdo" (Seb). Voir CHANGELOG 2026-09-07.
  • - GET /api/perf?period=week|month&offset=N — admin : dashboard "Suivi agents" (4e sous-onglet du Dashboard). Funnel contacts/RDV auto (mouvements réels du pipeline, mêmes règles anti-faux-changement que agent_goals.js) + ventes (fiches ventas créées, comme Briefing) + appels MANUELS + score pondéré (contact×1, appel×2, RDV×5, vente×15) + objectifs réutilisés de agent_goals. Renvoie {periodType, offset, periodStart, periodEnd, isCurrent, weekStart, weights, team, agents:[{contacts,appels,rdv,ventes,obj,score,spark[]}]}. Fenêtres en heure locale ; tables hebdo (appels/objectifs) comparées par chaîne de date (tz-proof). Module backend/agent_perf.js. Voir CHANGELOG 2026-09-07.
  • - POST /api/perf/appels — admin : saisie manuelle des appels d'un agent pour une semaine ({agent_id, week, appels}, table agent_appels).
  • - GET|POST /api/perf/plans, PATCH|DELETE /api/perf/plans/:id — admin : plans d'action de coaching (table agent_action_plans, statuts open/progress/resolved/escalated).
  • - POST /api/perf/notes, DELETE /api/perf/notes/:id — admin : notes de coaching / cobro (table agent_coaching_notes). GET /api/perf/plans renvoie aussi notes.
  • - 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/leads
  • - POST /api/leads/:lid/activities
  • - POST /api/leads/:lid/relance (avance la séquence 5 relances d'un cran ; {undo:true} recule d'un cran)
  • - POST /api/leads/:lid/properties
  • - POST /api/login
  • - POST /api/properties
  • - POST /api/web-lead — PUBLIC (sans clé), porte d'entrée du formulaire « lead chaud » du site vitrine moa.panelbay.com. Défenses : rate-limit 12/h par IP, honeypot (website), validation (WhatsApp ou email requis), CORS restreint à l'origine du site. Champs acceptés : nom/prenom, whatsapp/phone, email, budget (clé lt60|60-100|100-150|gt150|demande → budget_min/max), typologie, projet, message, langue. Lead créé source=site_web, intake_source=web_form, assigné au coordinateur (Gary), dédoublonné (email/tél, même logique que /api/leads/ingest) ; un doublon ajoute juste une activité. Origine du site autorisée via CORS_ORIGINS dans backend/.env.
  • - GET /api/moaops-docs/:saleId / POST /api/moaops-docs/:saleId (upload) / DELETE /api/moaops-docs/:saleId/:docId — justificatifs de commission attachés à une vente MOA (moa-ops/Airtable), requireCoordinatorOrAdmin. Voir CHANGELOG 2026-08-31.
  • - GET /api/leads/intake/mine — coordinateur/admin : historique des prospects saisis via l'intake. Le coordinateur (Gary) voit LES SIENS (filtre created_by=acting.id) ; l'admin voit tous les intake_source LIKE 'coordinateur_%'. Alimente l'onglet Coordination › Mes prospects. Chaque ligne porte son statut (à assigner / assigné à X + étape). Voir CHANGELOG 2026-09-22.
  • - PATCH /api/leads/intake/mine/:lid — coordinateur/admin : correction d'un prospect que le coordinateur a lui-même saisi (created_by=acting.id, ou n'importe lequel pour l'admin ; sinon 403). Champs bornés INTAKE_EDIT_FIELDS = name, whatsapp, email, source, residence_country, language, zone, notes (whatsapp normalisé + miroir phone). PAS de réassignation ni de changement d'étape ici (assignation = Matt/Seb, pipeline = l'agent). Trace une activité "Fiche corrigée par le coordinateur". Voir CHANGELOG 2026-09-22.
  • Relances automatiques (phase 1 : proposition + validation par l'agent)

  • - Objectif : relancer les leads qui ne répondent jamais / plus en suivant la séquence MOA Academy (J1 offre image+texte, J2 note audio, J3 appel après 18h30, J4 info valorisation, J5 question directe). Cible finale = envoi WhatsApp 100% auto, mais phase 1 = revue humaine : chaque agent valide/édite/envoie ses relances (validation de la voix avant l'auto).
  • - Moteur : backend/relance_engine.mjs (buildQueue). Détecte les leads silencieux (stage contacte|interesse|relance, relance_step < 5, numéro présent, assignés) qui n'ont jamais de message entrant ou plus depuis RELANCE_SILENCE_DAYS (défaut 7j), pas déjà relancés aujourd'hui (cadence 1 étape/jour), pas trop frais (RELANCE_MIN_AGE_DAYS, défaut 2j). Langue inférée (champ language sinon indicatif tél). Message généré par backend/scripts/relance_gen.py (OpenAI, voix de l'agent + méthode Academy ; repli déterministe si pas de clé). Plafond RELANCE_MAX_GEN (défaut 60) par run.
  • - Tables : relance_queue (une proposition = lead+étape ; statut pending|sent|skipped|stopped) et relance_settings (_global + par agent ; mode review=phase1 / auto=phase2, prévu). Réutilise leads.relance_step / relance_last_date et log en activité type='relance'.
  • - Build nocturne : cron 0 7 * * * → node relance_build.mjs (log data/relance_build.log). Rien n'est envoyé par le cron : il ne fait que remplir la file.
  • - Endpoints (auth ; agent = les siennes, admin = toutes ou ?agent=) : GET /api/relances (file), GET /api/relances/count (badge nav), POST /api/relances/refresh (reconstruit la file), POST /api/relances/:id/send (canal whatsapp → envoi via l'instance Evolution DE L'AGENT wa.sendText + trace wa_messages out + avance l'étape ; canal audio/call → marque fait sans envoi, l'agent réalise l'action), POST /api/relances/:id/skip (passe l'étape, avance quand même), POST /api/relances/:id/stop (arrête la séquence, relance_step=5).
  • - Garde-fous : envoi refusé si l'agent n'a pas d'instance WhatsApp open (400) ; un lead qui a répondu récemment sort de la file (replied_recently) ; J3 (appel) et J2 (audio) ne partent JAMAIS en auto (action manuelle). Onglet relances (nav) = ADMIN seulement (caché aux agents, décision Matt 2026-08-25 ; showFor.relances = admin dans applyGating). Phase 2 (auto) consommera la même file côté serveur quand mode=auto.
  • - Voix de l'agent (calibration des relances) : table agent_voice_profiles (agent_id PK, profile JSON, samples_analyzed). Remplie par l'agent via son Claude connecté au MCP du CRM : 3 outils MCP dans backend/mcp/tools.js : get_my_writing_samples (lit ses messages WhatsApp sortants, scoping ctx.uid), save_my_voice_profile (écrit le profil : style_summary + tone/greeting/closing/emojis/signature/languages/typical_phrases/sample_relance), get_my_voice_profile. Le générateur relance_gen.py injecte ce profil (_voice_block, priorité sur le style générique) quand il existe ; relance_engine.mjs le charge par agent (voiceProfile()). Guide agent : workspace/MOA-relances-monica-voix.md. NB : le MCP passe donc de strictement read-only à read + 1 write scopé (le profil de voix de l'agent lui-même).
  • API REST publique par clé (/api/v1) — agents

  • - Auth = clé API personnelle (Authorization: Bearer moa_live_... ou X-API-Key), une clé active par agent, hash SHA-256 en base (table api_keys), scoping identique au JWT (ensureLeadAccess sur req.acting). Surface bornée : aucune suppression, aucune route admin, pas de réattribution. Doc agents : docs/API.md.
  • - Lecture : GET /api/v1/me, /meta, /leads?stage=&q=&limit=&offset=, /leads/:id, /leads/:id/activities, /pipeline, /agenda.
  • - Écriture (scopée au porteur) : POST /api/v1/leads (crée assigné à soi, intake_source=api), PATCH /api/v1/leads/:id (contact + stage + next_action...), POST /api/v1/leads/:id/activities (note), POST /api/v1/leads/:id/relance ({}/{undo:true}). Rate-limit 120/min/clé. Étapes tracées en activité (mention API).
  • - Relances via l'API (pour le Claude/assistant de l'agent, PAS de MCP) : GET /api/v1/relances/voice-samples?limit= (mes messages sortants, matière pour analyser ma voix ; plancher 20), GET|POST /api/v1/relances/voice-profile (lire / enregistrer mon profil de voix dans agent_voice_profiles), GET /api/v1/relances/pending (mes leads silencieux du jour + étape due J1→J5 + step_instruction + history + last_reply ; l'appelant rédige le message dans sa voix), POST /api/v1/relances/send ({lead_id, message, channel, force}) = ENVOI réel : canal whatsapp envoie le texte via l'instance Evolution de l'agent, canal audio/call marque l'étape faite. Avance la séquence + trace. Garde-fous serveur (indépendants de l'appelant) : 409 si le lead a répondu (on ne relance que les silencieux), 429 si plafond relance_settings.daily_cap atteint (défaut 25/j, compté sur relance_queue.status='sent' du jour, source api incluse), 423 hors 9h-20h (bypass force:true), 400 si pas d'instance WhatsApp open ou pas de numéro. C'est le chemin retenu pour Monica (son assistant Claude Code workspace/monica-assistant porte le skill relances + sa clé API). Les outils MCP voix (get_my_writing_samples, etc.) restent dispo mais NON utilisés (le connecteur MCP ne marche pas chez eux, décision Matt 2026-08-25).
  • - Réponse libre + envoi de documents du catalogue via l'API (assistant de l'agent) : GET /api/v1/relances/replies (mes leads qui ont répondu et attendent, avec history/last_reply), POST /api/v1/messages/send ({lead_id, message}) = envoi d'un texte libre (réponse à une conversation, hors séquence de relance ; garde-fous : propriétaire, instance open, numéro présent ; PAS de plafond/horaires). Catalogue exposé en lecture sur la clé : GET /api/v1/catalog/brochures, /catalog/promoters, /catalog/promoters/:id/projects, /catalog/projects/:id/units (docs groupés par type, chaque fichier a un rel), /catalog/units/search?q=&city=&maxUsd=&hab=. Puis POST /api/v1/messages/send-doc ({lead_id, doc_path, caption}) = ENVOI réel d'un document (brochure/plan/liste de prix/image) : doc_path = le rel d'un doc du catalogue (sandboxé à moa-catalog/docs par catalog.resolveDoc), lu localement et envoyé en base64 via wa.sendMedia (nouveau, /message/sendMedia) sur l'instance de l'agent ; mediatype déduit du MIME (image/video/document), max 25 Mo (413 sinon). Trace wa_messages out + activité whatsapp_out. Garde-fous : propriétaire (ensureLeadAccess), instance open, numéro présent, sandbox path. C'est le chaînon qui manquait pour que l'assistant de Monica envoie une brochure à un client actif sans chasser le doc en interne (ajout Matt 2026-09-15, voir CHANGELOG). Skill agent mis à jour : workspace/monica-assistant skill relances section C.
  • - Envoi 100% automatique (phase 2, côté serveur, décision Matt 2026-08-25) : une IA conversationnelle (le Claude de l'agent) REFUSE d'envoyer seule des messages en se faisant passer pour quelqu'un (garde-fou modèle, non contournable, correct). Donc l'envoi autonome est fait par le CRM lui-même, de façon déterministe : runAutoSend() dans relance_engine.mjs, lancé par relance_autosend_run.mjs (cron 30 10 * * *, log data/relance_autosend.log). Ne tourne QUE pour les agents relance_settings.mode='auto' + enabled=1 + auto_confirmed=1 (colonne ajoutée ; le jour 1 doit être validé par l'admin avant de passer auto_confirmed=1). Garde-fous : horaires 9h-20h, plafond daily_cap (25/j, compté sur relance_queue status='sent' du jour), stop si le lead a répondu (evaluateLead exclut replied_recently), opt-out (scan des messages entrants sur OPTOUT_RE → jamais relancé), J2 (audio)/J3 (appel) auto-avancés sans envoi (touche humaine, tracés status='skipped' source='auto'), envoi seulement si instance WhatsApp open. Message généré dans la voix de l'agent (agent_voice_profiles via relance_gen.py). Testé : les 3 gates (not_confirmed, wa_not_open, out_of_hours) bloquent bien tout envoi ; Monica est en mode=auto daily_cap=25 mais auto_confirmed=0 (donc rien ne part). Flux de mise en route : (1) Monica enregistre sa voix, (2) le build 7h remplit l'onglet Relances admin avec ses propositions en voix, (3) l'admin valide le jour-1 depuis l'onglet, (4) passer auto_confirmed=1 pour Monica → le cron 10h30 envoie tout seul les jours suivants.
  • - Gestion des clés : self-serve GET|POST|DELETE /api/me/api-key (auth JWT) ; admin GET|POST /api/admin/api-keys, DELETE /api/admin/api-keys/:id (requireAdmin). CLI : node scripts/issue_api_key.js <email|user_id> [label] (affiche la clé en clair UNE fois). Régénérer révoque l'ancienne.
  • 6. Intégrations & secrets

  • - moa-catalog (catalog.db) : intégration lecture seule via backend/catalog.js. Chemin en dur /root/workspace/moa-catalog. Si catalog.db bouge, mettre à jour ce chemin.
  • - Secrets / clés : JWT_SECRET via env (backend/.env si présent, sinon défaut dev). Pas d'autre secret.
  • - Connecteur MCP (serveur, OAuth 2.1) : le CRM s'expose comme serveur MCP distant pour que chaque agent branche SON propre Claude (claude.ai → Connectors → URL https://crm-moa.panelbay.com/mcp) et consulte SON portefeuille en lecture seule. Code isolé dans backend/mcp/ (store.js tables/oauth, provider.js OAuth provider, tools.js outils read-only, index.js wiring), monté par mountMcp(app) dans server.js AVANT le catch-all SPA. Auth = OAuth 2.1 + PKCE + DCR réutilisant les comptes CRM (login /mcp/login), jetons opaques hashés (sha256) stockés dans les tables mcp_oauth_*, scopés par uid. Scoping serveur (prompt-proof) : admin voit tout, sinon assignee_id=uid seulement (miroir de GET /api/leads). Table mcp_audit = journal de chaque appel d'outil. Var d'env optionnelle MCP_ISSUER_URL (défaut https://crm-moa.panelbay.com). Outils actuels (lecture seule) : whoami, list_my_leads, get_lead, lead_activities, pipeline_summary, agenda. Prérequis côté agent : un plan Claude payant (les connecteurs custom sont réservés aux plans payants). Prochaines étapes prévues : outils d'écriture scopés (créer tâche/note, changer étape) + fonctions IA intégrées à quota par agent.
  • 7. Pièges connus / à savoir

  • - La classe CSS .prop-status est en position:absolute (pour les cartes de biens). NE PAS la réutiliser dans un tableau : la pastille de statut s'échappe en haut à droite. Dans la table d'unités on utilise pill + st-* sans prop-status.
  • - Le catalogue est lecture seule. Ne jamais écrire dans catalog.db depuis le CRM (c'est moa-catalog qui l'alimente).
  • - Le mapping promoteur → dossier docs/ se fait par normalisation du nom (accents/casse/ponctuation retirés). "Veralta/Altacreo" ↔ dossier Veralta-Altacreo. Si un promoteur n'a pas de docs, le badge n'apparaît pas.
  • - Classification des documents (classifyDoc dans catalog.js) : par mots-clés sur le chemin normalisé complet, PAS sur l'arbo (les drives sont rangés de façon hétérogène, par catégorie OU par projet, jusqu'à 7 niveaux). Ordre = premier match gagne (brochure > précio > mémoire > ficha > plano > matériel > légal > autre). Ajuster les kw de DOC_TYPES si un type est mal rangé.
  • - Rattachement doc → projet (docsForProject) : heuristique = si le nom du projet normalisé (≥4 car.) apparaît dans le chemin d'au moins un doc, on ne montre que ceux-là (scope=project) ; sinon on retombe sur TOUS les docs du promoteur (scope=promoter, cas Civis/Zuba/RIZ rangés par catégorie). Imparfait mais bien plus utile que la liste à plat.
  • - Recherche cross-devise : les prix sont mélangés USD et PYG. Le filtre/tri/affichage se fait en USD via une colonne calculée au taux du jour. Le taux réel USD/PYG est écrit dans moa-catalog/rate.json par scripts/update_rate.py (cron matinal, open.er-api.com + secours), relu à la volée par catalog.js (pygPerUsd(), cache 1h, repli 6000 si absent). NE PAS remettre un taux en dur. La devise d'origine est conservée en base ; dans la recherche, un prix converti depuis le ₲ porte une bulle ₲ avec le prix réel en tooltip.
  • - i18n OBLIGATOIRE (3 langues) — règle Matt (2026-08-10). Le CRM tourne en ES / FR / EN. Toute nouvelle chaîne visible par l'utilisateur DOIT passer par t('cle') avec une entrée { es, fr, en } dans frontend/js/i18n.js (jamais de texte en dur, ni dans el(...) ni dans les templates innerHTML). Vérifier après chaque ajout avec le script d'audit (parse à accolades équilibrées) : 0 clé incomplète, 0 clé t() sans entrée. Les libellés de nav se traduisent via applyI18n/navLbl + titles. Rappel : la locale est imposée par le serveur au boot (users.locale), window.I18N.setLocale() ne fait que mémoire+localStorage.
  • - import * as catalog est en milieu de server.js (hoisté, valide en ESM) : le laisser au niveau module, pas dans un bloc.
  • - Toute réponse que le front stocke dans state.me DOIT porter les features résolues (helper meJson(u) dans server.js = publicUser + resolveFeatures). Un publicUser(u) nu à cet endroit = l'agent ne voit AUCUN onglet (écran « à venir », WhatsApp seul) jusqu'au prochain rechargement, car featureOn() est faux partout (incident Morgane 2026-09-24, juste après l'activation de son lien d'invitation). Concerné : login, /api/me, POST /api/invite/:token, POST /api/change-password, PATCH /api/me, GET /api/admin/user-view/:id.
  • - Nouvel agent = tous les onglets, sans étape manuelle (règle Matt 2026-09-15, verrouillée le 2026-09-24). Source de vérité des onglets = la ligne « Défaut (nouveaux agents) » de l'onglet Accès agents (table feature_flags, 8/8 ON) ; add_agent.mjs n'écrit aucune exception par agent et affiche le contrôle ONGLETS=8/8 à la création (avertit si un défaut est coupé). Le « full access » (accueil sur le Pipeline + recherche globale, champ full_access) est automatique pour tout role='agent' ; FULL_AGENT_EMAILS (backend/.env) ne sert plus qu'à l'étendre à un autre rôle (Gary). Après add_agent.mjs il ne reste que les champs RH et l'envoi du lien.
  • 8. CHANGELOG

  • - 2026-09-28 : Attribuer un lead le renvoie TOUJOURS dans la colonne de base « Sans contact ». Demande Matt : quand il assigne un lead à un agent, il doit toujours réapparaître en « Sans contact » (colonne de base), parce que d'anciens leads déjà avancés dans le pipeline atterrissaient au milieu du board du nouvel agent (mauvais endroit). Backend (server.js), deux chemins d'attribution alignés : (1) PATCH /api/leads/:lid : dès qu'on pose un assignee_id non nul différent de l'actuel ET que l'appel ne fixe pas déjà une étape explicite, on force b.stage='sans_contact' (le bloc de log d'étape existant trace <ancienne> → Sans contact + stage_changed_at). (2) POST /api/leads/bulk-assign : dans la boucle de transaction, chaque lead réellement (ré)assigné dont l'étape n'est pas déjà sans_contact est remis à sans_contact (+ stage_changed_at) avec une activité d'audit <ancienne> → Sans contact (attribution), en plus de la note « Assigné à X (assignation groupée) ». Une désattribution (assignee_id → null) ne touche jamais l'étape. node --check OK, moa-crm.service redémarré. Vérif fonctionnelle à confirmer par Matt sur une vraie attribution (Playwright indisponible en sandbox root).
  • - 2026-09-21 : Vue agent « Mes commissions » : chaque agent voit en direct le statut de SES ventes (encaissées vs en attente de versement). Demande Matt : dans l'onglet Ventes, un agent doit pouvoir consulter ses ventes passées et leur statut live, avec X commissions déjà encaissées (dates comprises) et Y commissions en attente de versement (statut du dossier), uniquement les siennes. Backend (server.js) : nouvel endpoint GET /api/my-commissions (auth, tout rôle) qui tire le board Airtable live via moa-ops (/api/ventes/board) et filtre sur le nom de l'agent connecté (req.acting.name) via une normalisation accents/espaces (normNameForMatch : NFD + strip diacritiques + collapse espaces → gère "Raphaël Sterckx" double espace, "Sébastien"→"Sebastien"). Deux groupes : cobradas (statut Commission recue OU une Date reception commission renseignée = règle robuste, rattrape les "Avis demandé" déjà payés) et pendientes (statuts actifs Contrat en Attente/Entrega en Attente/Commission en attente/Avis demandé) ; Perdu exclu ; annexes (Cochera/Baulera) sans commission propre masquées (portées par l'appart parent). Totaux séparés USD/PYG (pas de conversion inventée). Lecture seule, aucune écriture. Frontend (renderVentas, vue agent uniquement) : panneau « Mes commissions » au-dessus de « Mes ventes », deux sous-blocs avec compteur + total, cartes comCard (projet·unité, client, montant, date d'encaissement ou statut du dossier traduit). i18n ES/FR/EN complète (ventas.com*, saleStatus.*). CSS .vt-commissions/.com-*/.vs-com-*. Réservé aux agents : admin/coordinateur (Gary)/finance (Giannina) gardent le board complet renderVentesMoa ; le scoping "ses ventes à lui" ne concerne que le rôle agent. Testé : GET /api/my-commissions mocké par token jetable pour Facundo (4 encaissées, 25 427 $), Mathis (1 encaissée 6 284 $ + 1 en attente 37J), Alexis (1 encaissée 9 964 $ Michael Albert, encaissée le 28/08), Monica (USD + 1 commission PYG bien isolée), Raphaël (10 encaissées 48 034 $ + 3 en attente) → scoping, dates, statuts et totaux corrects. node --check server.js + app.js/api.js/i18n.js OK. Cache-bust ?v=20260921mycom + SW moa-crm-v70-20260921mycom. moa-crm.service redémarré. NB Playwright indisponible (sandbox root), vérif visuelle non faite ; rendu réutilise les helpers existants (el/ventaSkeleton/money/kmoney/t).
  • - 2026-09-18 : Fix upload de contrat promoteur (Valentina) : « Erreur serveur » sur tout PDF > 1 Mo. Signalé par Matt : l'assistante ne pouvait pas déposer le PDF d'un contrat dans un dossier promoteur (onglet Promoteurs → section Contrats). Cause : nginx, pas l'app. Les deux vhosts de ce CRM (crm-moa.panelbay.com et crm.bouthors-m.tenga.run, tous deux proxy vers 127.0.0.1:8310) n'avaient aucun client_max_body_size → défaut nginx 1 Mo → tout POST plus gros est rejeté en 413 AVANT d'atteindre Node (log nginx : client intended to send too large body: 1512227 bytes ... POST /api/promoter-terms/.../contracts). Le 413 renvoie une page HTML sans champ JSON .error, donc le front (api.js reqForm) retombe sur le message générique « Erreur serveur ». Le back acceptait pourtant déjà 25 Mo (multer, uploads.js). Fix : client_max_body_size 25m; ajouté au bloc server des deux confs (/etc/nginx/sites-enabled/crm-moa.panelbay.com.conf + crm.bouthors-m.tenga.run.conf), aligné sur la limite multer. nginx -t OK, systemctl reload nginx (pas de restart, zéro coupure). Testé : POST d'un corps de 1,5 Mo sur /api/promoter-terms/test/contracts → passe de 413 à 401 (nginx laisse passer, l'app demande l'auth) = corrigé. NB : aucune modif de code app ni de service. Piège latent identique sur crm-resident.panelbay.com / crm-rp.bouthors-m.tenga.run (CRM RP, pas de client_max_body_size non plus) ; les CRM Properties ont déjà 20m. À relever si le CRM RP gère des uploads.
  • - 2026-09-15 : L'assistant d'un agent peut envoyer une BROCHURE/document du catalogue à un client via l'API (le chaînon qui manquait). Contexte : analyse du suivi WhatsApp de Monica (14/09) → une grosse part de son temps partait à réclamer des docs en interne et à envoyer des brochures à la main ; son assistant Claude Code savait déjà envoyer du TEXTE (/api/v1/messages/send) mais ni voir le catalogue ni envoyer un fichier. Deux manques : (1) le wrapper Evolution ne faisait que sendText, (2) l'API à clé n'exposait pas le catalogue. Fix. (a) backend/wa_evolution.js : nouvelle fonction sendMedia(name, number, {mediatype, mimetype, media(base64), fileName, caption}) → POST /message/sendMedia/<instance> (copie de l'implémentation prouvée du CRM MP). (b) backend/server.js, router /api/v1 : catalogue en lecture sur la clé (GET /catalog/brochures, /catalog/promoters, /catalog/promoters/:id/projects, /catalog/projects/:id/units, /catalog/units/search) + POST /api/v1/messages/send-doc ({lead_id, doc_path, caption}) : doc_path = le rel d'un doc du catalogue, résolu par catalog.resolveDoc (sandbox moa-catalog/docs, bloque le path-traversal), lu en base64, envoyé via wa.sendMedia sur l'instance de l'agent ; MIME → mediatype (image/video/document), plafond 25 Mo (413). Trace wa_messages out (📎 <fichier>) + activité whatsapp_out. Garde-fous : ensureLeadAccess (propriétaire), instance open, numéro présent. Testé de bout en bout (clé jetable mintée pour le compte de Matt, révoquée après) : GET /catalog/brochures = 101 brochures ; send-doc avec doc_path='../../etc/passwd' → 404 (sandbox OK) ; envoi réel d'une brochure PDF (Altea Tower) en self-send vers le WhatsApp de Matt (aucun client touché) → {ok:true, sent:true}, reçu, tracé. Effet de bord rencontré et nettoyé : le lead de test ayant le propre numéro de Matt, relinkLeadWa a rattaché ses 207 messages d'historique réel au lead de test ; cleanup ciblé (suppression du seul message de test, lead_id remis à NULL sur les 207, lead + activités supprimés, clé révoquée) → état restauré, seule la clé de Monica reste active. node --check OK (server.js + wa_evolution.js), moa-crm.service redémarré. Skill agent mis à jour : workspace/monica-assistant skill relances section C (trouver le doc via le catalogue → montrer à Monica → send-doc après son OK). NB anti-ban : send-doc (comme /messages/send texte) n'a PAS de plafond/horaires (réponses à des conversations actives, pilotées par un humain qui valide) ; si l'usage grossit, ajouter un plafond quotidien d'envois sortants comme sur les relances.
  • - 2026-09-11 : Fix : les brochures institutionnelles d'un promoteur n'apparaissaient plus dans le détail d'un projet dès que le drive était rangé par projet (ex. "Æther by Civis" ne montrait aucune brochure, seulement ses plans). Signalé par Matt (« plein de brochures qui apparaissent pas dans les biens disponibles, notamment Civis Æther »). Cause dans backend/catalog.js (docsForProject) : quand le nom du projet matchait des fichiers (les plans/fiches rangés dans un sous-dossier au nom du projet), on renvoyait scope='project' avec UNIQUEMENT ces fichiers, et on ne retombait jamais sur les docs communs du promoteur (brochures institutionnelles, présentation entreprise) qui vivent à la racine du dossier promoteur et ne contiennent pas le nom du projet. Résultat : 8 projets Civis sur 12 (dont Æther) n'affichaient aucune brochure. Fix : docsForProject(projectNom, promoterNom, siblingNoms) ajoute désormais, en plus des docs spécifiques au projet, les docs de type brochure qui ne sont rattachés à AUCUN autre projet du même promoteur (exclus via siblingNoms = les noms des autres projets, pour ne pas faire fuiter la brochure d'un projet voisin). units() passe la liste des projets frères (requête SELECT nom FROM projets WHERE promoteur_id=?). Restreint volontairement au type brochure (les mémoires/plans restent bien spécifiques au projet, pas de bruit cross-projet). Testé : import direct de catalog.js post-fix → Æther passe de ficha:47 (0 brochure) à brochure:4, ficha:47 (les 4 brochures institutionnelles Civis : Corto, Curta, Longa, Market Overview) ; Jardinia garde sa brochure propre + les 4 institutionnelles ; sweep sur les 152 projets = 0 erreur, 55 projets ont désormais ≥1 brochure. node --check OK, moa-crm.service redémarré (catalog.js chargé à l'import). Bénéficie à TOUS les promoteurs rangés par projet, pas seulement Civis. NB : cas limites préexistants non traités (noms de projets qui se contiennent l'un l'autre comme "Civis X" ⊂ "Civis XI", ou dossiers "Arazá" sans le "I/II" du nom de projet) hors scope, mais le fix n'ajoute que des brochures (jamais ne retire de doc), donc aucune régression.
  • - 2026-09-07 : Tracking source des lead magnets : la colonne Source affiche le lead magnet PRECIS (ex. "VSL IMMO"), plus le generique "lead_magnet". Demande Matt : voir en un coup d'oeil que le lead vient bien de la VSL et surtout QUEL lead magnet, sans se soucier de l'etiquette generique. Constat : le webhook systeme.io (aios-automations route /moa/lead-magnet-notif) creait tous ces leads avec source="lead_magnet" (en dur) et ne mettait le tunnel precis que dans le tag/la note (d'ou l'impression d'incoherence : colonne = "lead magnet", pastille ⓘ = "VSL IMMO", alors que c'est la meme origine a deux granularites). Fix a 2 champs : source = libelle VISIBLE = le tunnel/brochure precis (ex. "VSL IMMO"), filtrable dans la colonne Source ; intake_source = canal MACHINE = auto_lead_magnet (permet de regrouper toute la famille "lead magnet" en reporting sans polluer le libelle). BACKEND moa-crm (server.js POST /api/leads/ingest) : accepte desormais un intake_source explicite dans le payload (sinon auto_<source> comme avant), et source accepte jusqu'a 80 car (libelles descriptifs). aios-automations (server.py) : _moa_crm_ingest gagne un param intake_source (transmis dans le payload) ; la route lead magnet MOA pose source=brochure or "Lead magnet (systeme.io)" + intake_source="auto_lead_magnet". Backfill : les 6 leads existants source='lead_magnet' passes a source=<tag> (tous "VSL IMMO"), intake_source inchange. Teste : ingest curl (source="VSL IMMO" visible + intake_source="auto_lead_magnet" en base, lead de test supprime) ; node --check + parse Python OK ; moa-crm.service + aios-automations.service redemarres. NB : le nettoyage plus large de la colonne source (elle melange encore canaux, campagnes Meta, titres de brochures et ~200 valeurs parasites "Date lead: JJ/MM/AAAA" issues d'un import) reste a faire, hors scope de cette demande ciblee.
  • - 2026-09-03 : Navigation mobile : barre du bas (3 raccourcis + « Menu ») au lieu d'obliger la rotation en paysage pour voir le menu. Demandé par Matt : sur mobile en portrait, la sidebar était en display:none sous 720px (seule échappatoire = tourner le téléphone pour repasser au-dessus du breakpoint). FRONTEND uniquement (frontend/index.html, frontend/styles.css, frontend/js/app.js, frontend/js/i18n.js clé nav.more, frontend/sw.js cache-bust v67). La sidebar existante (#nav) devient le tiroir mobile (off-canvas, transform:translateX, plus de display:none) : ouverte via .sidebar.mobile-open, fermée par le bouton ✕ dans son en-tête, un backdrop (#mobile-nav-backdrop), un item du menu, ou Échap. Nouvelle barre fixe en bas d'écran (#mobile-tabbar, visible seulement <=720px) : syncMobileTabbar() (app.js) clone dynamiquement les 3 premiers items visibles de #nav (donc déjà filtrés par applyGating(), cohérent par rôle : admin/coordinateur/agent/assistant voient chacun leurs vrais raccourcis) + un 4e bouton « Menu » qui ouvre le tiroir complet si plus de 3 items sont accessibles. Rebuild appelé depuis applyGating() (changement de droits/vue globale) et applyLocale() (changement de langue), donc reste sync sans code dupliqué (labels/icônes/badges restent la seule source #nav, pas de traduction dupliquée). navigate() étendu pour togguer .active sur les items de la barre du bas aussi. Ajustements CSS pour ne rien faire chevaucher par la nouvelle barre (~64px + safe-area iOS) : .content padding-bottom, .assign-bar, #toast, .pwa-install-btn.show. Pas de redémarrage service nécessaire (fichiers statiques servis avec Cache-Control: no-cache, aucun changement backend) ; cache-bust query strings (?v=20260903mobilenav) + version du service worker (moa-crm-v67-20260903mobilenav) pour forcer le rafraîchissement PWA. Testé : node --check sur app.js/i18n.js OK, brace-balance CSS OK, html-validate sur index.html (0 nouvelle erreur, l'unique erreur d'accessibilité introduite par le 2e <nav> a été corrigée en donnant un aria-label distinct aux deux). Vérification visuelle Playwright impossible dans cet environnement (sandbox Chromium indisponible en root) : à confirmer par Matt sur son téléphone au prochain chargement.
  • - 2026-09-01 : Fix bug bloquant : création d'un prospect impossible avec « Agent : — non assigné — » (« Erreur serveur inattendue »). Signalé par Matt (capture d'écran, formulaire « Nouveau prospect »). Cause : le <select> Agent envoie assignee_id: '' (chaîne vide) quand aucun agent n'est choisi ; côté backend row[f] = b[f] ?? null ne convertit PAS '' en null (seuls null/undefined le sont), donc l'INSERT tentait assignee_id='', qui viole la contrainte FOREIGN KEY (assignee_id) REFERENCES users(id) (aucun user d'id '') → SqliteError: FOREIGN KEY constraint failed côté serveur (log confirmé : Sep 01 15:10:58 ... unhandled error: SqliteError: FOREIGN KEY constraint failed, déjà 5 tentatives échouées avant le fix). Bug présent depuis toujours pour tout admin créant un lead sans assigner d'agent (les non-admins ne sont pas touchés : assignee_id est forcé à leur propre id). Fix (server.js) : normalisation if (b.assignee_id === '') b.assignee_id = null; ajoutée en tête de POST /api/leads (création) ET PATCH /api/leads/:lid (édition, même bug latent si on désassigne un lead existant via le formulaire de détail). node --check OK, moa-crm.service redémarré. Testé : requête directe à POST /api/leads avec assignee_id:'' (JWT admin de test signé avec JWT_SECRET, lead de test créé puis supprimé) → 200, lead créé avec assignee_id:null (avant le fix : 500). Aucune migration DB nécessaire (le bug était uniquement dans la normalisation des entrées, pas le schéma).
  • - 2026-08-31 : Justificatif de commission sur une vente MOA (fiche « Ventes MOA »). Demande Gary (WhatsApp 31/08, relayée par Matt) : comment charger la preuve de commission reçue + noter le montant. Le montant (commission) et la date (date_commission) étaient déjà éditables dans la fiche (existant, section cycle de vie) ; ce qui manquait = le DOCUMENT justificatif, car moa-ops n'a aucun stockage de fichiers (Airtable + SQLite de suivi seulement). Solution : le justificatif vit côté CRM, dans la table générique documents (entity_type='venta_moa', entity_id=id Airtable de la vente). BACKEND (server.js) : comprobante_comision ajouté à DOC_KINDS ; 3 nouvelles routes requireCoordinatorOrAdmin : GET /api/moaops-docs/:saleId (liste), POST /api/moaops-docs/:saleId (upload, réutilise persistFiles/magic-bytes existant), DELETE /api/moaops-docs/:saleId/:docId ; GET /api/documents/:id (téléchargement gardé) gagne une branche entity_type==='venta_moa' → allowed = canSeeAllSales(req) (admin + coordinateur, 403 agents, cohérent avec le reste du module Ventes MOA). FRONTEND (app.js openMvtModal) : nouvelle section « Comprobante de comisión » sous les champs commission/date de réception (liste des justificatifs + upload + suppression), fonctions mvtLoadProofs/mvtUploadProof/mvtDeleteProof, api.js (moaopsDocs/moaopsDocUpload/moaopsDocDelete), i18n vm.commissionProof/vm.proof* (ES/FR/EN) + clés génériques saving/saved ajoutées (comblent un trou i18n préexistant, réutilisées ailleurs dans le module Ventes MOA). CSS .mvt-proof-*. NB connexe : en creusant la 1re demande de Gary (bouton « nouveau client depuis le début », WhatsApp 27/08), constat que le bouton « + Nueva venta » existe déjà depuis le 2026-08-28, mais son sélecteur d'agent ne listait QUE les agents ayant déjà au moins une vente enregistrée dans Airtable (agentMap déduite des ventes passées) : Antonella, Gia, Lucas, Thiago, Vsevolod (agents CRM actifs sans vente encore saisie) en étaient absents. Fix côté moa-ops (ventes.js) : nouveau loadAgentsRegistry() qui lit la table Airtable « Agents Immobilier » (tblXzb5xxZPN5irCH, agents Statut=Actif) et alimente agentMap EN PRIORITÉ, complétée par les noms déduits des ventes (couvre les affiliés/agents inactifs avec historique). Aucune nouvelle permission d'écriture (lecture d'une table déjà accessible via AIRTABLE_API_KEY). Karen reste introuvable : ni dans les users du CRM, ni dans « Agents Immobilier » Airtable, ni dans aucune vente. Avant que Gary puisse saisir ses ventes, il faut soit l'onboarder officiellement (compte CRM + fiche Airtable, comme Anthony le 2026-08-31), soit clarifier s'il s'agit d'un nom mal orthographié pour un agent déjà existant. Signalé à Matt, pas d'action prise sans confirmation (aucune donnée business inventée). Testé : upload PDF réel via API (kind comprobante_comision, uploaded_by_name résolu), rejet magic-bytes sur faux PDF, téléchargement 200 (Gary) / 403 (agent lambda), suppression + nettoyage fichier physique vérifié (aucune trace orpheline) ; agentMap moa-ops passée de 10 à 17 agents pour Gary (Antonella/Lucas/Thiago/Vsevolod désormais sélectionnables). node --check OK sur les 2 apps (server.js moa-crm, ventes.js moa-ops), moa-crm.service + moa-ops.service redémarrés.
  • - 2026-08-31 : Nouvel agent (Anthony Tremellat) + fiche RH sur users + départ d'agent (Manuel Meffert) + fix « vue globale » qui proposait encore les archivés. Demande Matt (3 volets). (A) Nouveau champ RH sur users : migration additive db.js (personal_email, phone, ci_number, birth_date, nationality, address, languages, contract_date), admin only, PAS exposé par publicUser() (donc invisible des agents/de la vue globale) : pas encore d'écran dédié pour les lire/éditer, saisi ici en direct côté DB, à faire évoluer en formulaire si Matt en recrée souvent. Compte créé : Anthony Tremellat, login anthonytremellat@moa-realestate.com (convention prénomnom@moa-realestate.com, comme tous les agents), mot de passe temporaire MoaCRM2026! (must_change_password=1), role='agent', fiche RH remplie (CI 9395704, né 17/10/1985, nationalité française, Colombia 222 barrio La Catedral Asunción, français, contrat 31/08/2026). Ajouté au récap /root/workspace/moa-crm-acces-agents.md. (B) Manuel Meffert archivé (même mécanisme que l'archivage d'Abel du 2026-08-24 : archived=1, clés API révoquées, login bloqué). Vérifié : 0 lead assigné au moment de l'archivage, donc rien à libérer/réattribuer cette fois (pas de script de réassignation nécessaire). (C) Mécanisme réutilisable pour la PROCHAINE fois (au lieu d'un script ad hoc à chaque départ) : nouveau tag reassigner (« À réassigner en priorité », db.js TAGS), posable à la main sur un lead comme n'importe quel tag ; server.js (/api/dashboard, requête unassignedList) fait remonter en tête tout lead non attribué portant ce tag, avant le tri par fraîcheur habituel. Donc pour un futur départ avec des leads actifs : désassigner ses fiches (assignee_id=null) + leur poser le tag reassigner → elles sautent en tête du panneau « Leads à assigner ». (D) Fix « vue globale » (impersonation admin) : renderViewAsPicker (app.js) proposait TOUS les comptes, y compris les archivés (Manuel y serait resté visible indéfiniment) → filtré !u.archived (sauf si on est déjà en train de le consulter, pour pouvoir en sortir proprement). Cache-bust app.js ?v=20260831reassign, SW moa-crm-v63-20260831reassign. node --check OK (server.js+db.js), moa-crm.service redémarré (migration appliquée, colonnes présentes, /api/meta renvoie le tag reassigner). Pas encore testé en Playwright (pas de leads réels concernés côté Manuel pour valider le tri visuellement) : à confirmer par Matt au prochain vrai départ avec book actif.
  • - 2026-08-27 : Fix « Vue globale » (impersonation admin) : les onglets de l'agent s'affichaient tous « à venir » alors que ses accès étaient actifs. Symptôme (signalé par Matt via 2 captures : Monica en vue globale ne voyait QUE WhatsApp + « Próximamente » partout, alors que la matrice d'accès montrait tous ses onglets ON). Cause : setImpersonation(uid) posait state.me = target en piochant l'objet dans state.users, qui est un publicUser sans le champ features → featureOn(v) renvoyait false pour tout → nav masquée (seul WhatsApp via wa_enabled) et vues gated en renderComingSoon. L'accès RÉEL de l'agent n'était pas en cause (Monica avait bien tous ses onglets, vérifié en base) : seul l'aperçu admin mentait. Fix BACKEND : GET /api/admin/user-view/:id (requireAdmin) renvoie { ...publicUser(u), features: resolveFeatures(u) }. Fix FRONTEND : setImpersonation devient async et charge API.userView(uid) pour poser un state.me avec les vrais features (repli sur l'objet sans features si l'appel échoue). api.js : userView. Cache-bust ?v=20260827impfix, SW moa-crm-v61-20260827impfix. moa-crm.service redémarré, node --check OK. Testé : endpoint renvoie les features de Monica (tous true) ; UI Playwright (admin → vue globale Monica) : nav affiche désormais Pipeline/Prospects/Tâches/Assistant/Biens/Promoteurs/Ventes/WhatsApp et Pipeline s'affiche (plus de « à venir »). NB : « Clients » reste un sous-onglet de Prospects (consolidation nav), donc absent de la barre principale, normal.
  • - 2026-08-27 : Onglet « Accès agents » refait en MATRICE (membres × onglets) avec édition groupée. Demande Matt : au lieu des toggles empilés, une grille (colonnes = onglets en haut, lignes = membres à gauche), possibilité de modifier plusieurs cases pour plusieurs agents d'un coup, et une case « tout sélectionner ». BACKEND (server.js) : nouvel endpoint POST /api/admin/feature-flags/bulk (requireAdmin) { user_ids[], views[], enabled: true|false|null } → écrit/supprime les exceptions feature_flags_user en UNE transaction (null = reset au défaut global, user_ids inconnus ignorés, views filtrées sur AGENT_VIEWS, 400 si sélection vide). Endpoints existants (/global, /user) inchangés. FRONTEND (app.js renderAccesAgents réécrit) : table .accm-table = 1re ligne « Défaut (nouveaux agents) » (toggle les flags globaux) puis 1 ligne/agent ; colonnes = les 8 onglets AGENT_VIEWS. Case = toggle vert on/off (état effectif = exception sinon défaut global) sauvegardé optimiste. Sélection : case « tout sélectionner » dans le coin + une case par ligne agent → barre groupée (.acc-bulkbar) « N sélectionné(s) · Tout activer · Tout désactiver · Réinitialiser au défaut » (via l'endpoint bulk). Clic sur l'en-tête d'une colonne = bascule tout l'onglet pour les agents sélectionnés (ou tous si aucun coché) : ON pour tous, sinon OFF si déjà tous ON. api.js : setFeaturesBulk. i18n acc.matrixSub/member/selectAll/defaultRow/colBulkTip/selectedN/enableAll/disableAll/resetDefault/colBulkHint/matrixNote (ES/FR/EN). CSS .accm-* + .acc-bulkbar. Périmètre : seuls les agents (role='agent') sont listés (les toggles ne gouvernent qu'eux ; admins = tout, coordinateur/assistante = rôle fixe), noté sous la table. Cache-bust ?v=20260827accmatrix, SW moa-crm-v60-20260827accmatrix. moa-crm.service redémarré, node --check OK. Testé : API bulk (disable 2 agents → overrides false ; reset → héritent le défaut ; 400 si sélection vide / onglet inconnu) ; UI Playwright admin (matrice 8 colonnes × 11 agents + ligne défaut = 96 toggles ; « tout sélectionner » → barre « 11 sélectionné(s) » ; clic en-tête « Clients » bascule toute la colonne ; toggle simple OK) ; état d'accès réel snapshoté puis restauré à l'identique (48 lignes) après tests, zéro pollution.
  • - 2026-08-27 : Contrats promoteurs + date de fin (confidentiels) dans le tableau des conditions commerciales, et visibilité agents conditionnée au contrat. Demande Matt : l'assistante doit pouvoir rentrer les contrats signés (fichiers) et la date de fin par promoteur ; contrats + date restent confidentiels (agents jamais) ; toggle « cacher les promoteurs sans contrat » coché par défaut ; les agents ne voient un promoteur QUE s'il y a un contrat. Décisions Matt : « avec contrat » = fichier de contrat chargé ET date de fin non expirée ; l'assistante peut aussi ouvrir les contrats (pas seulement les admins) ; agents jamais. DATA (db.js) : colonne promoter_terms.contract_end TEXT (migration idempotente) ; fichiers stockés dans la table générique documents (entity_type='promoter_contract', kind='contract', hors web-root, validés magic-bytes PDF/JPG/PNG/WEBP). BACKEND (server.js) : ptContractInfo() calcule contracts/contract_end/has_contract/contract_expired (has_contract = ≥1 fichier + date renseignée + contract_end >= today local) ; ptShape les expose (GET admin+assistante). POST /api/promoter-terms/:id/contracts (upload, requirePromoterEditor), DELETE /api/promoter-terms/:id/contracts/:docId (requirePromoterEditor), contract_end accepté par POST/PATCH ; DELETE promoteur nettoie fichiers+lignes documents. Téléchargement via /api/documents/:id : branche promoter_contract autorisée pour isPromoterEditor(req.acting) (admin + assistante), 403 agents. Public agents (/api/promoter-terms/public) : filtré à has_contract uniquement, et n'expose JAMAIS contract_end (ni notes). FRONTEND (app.js renderPromoterTermsPanel, utilisé par admin ET assistante) : colonne « Contrat » (✓ jusqu'au / ⚠ Expiré le / Date manquante / Sans contrat), toggle « Cacher les promoteurs sans contrat » coché par défaut (état ptHideNoContract), éditeur enrichi = champ date contract_end (badge 🔒 confidentiel) + section « Contrats signés » (upload multi-fichiers, liste avec ouverture/suppression, ouverture réservée éditeurs). api.js : promoterTermAddContracts/promoterTermDeleteContract. i18n pt.contract* (ES/FR/EN). CSS .pt-ok/.pt-conf/.pt-hide-toggle/.pt-contracts*. Cache-bust ?v=20260827contracts, SW moa-crm-v59-20260827contracts. moa-crm.service redémarré, node --check OK. Testé bout-en-bout (API, 3 rôles) : upload par Valentina → has_contract false sans date, true avec date future + fichier, false si date passée (contract_expired) ; download admin 200 / Valentina 200 / agent 403 ; public agent = seulement promoteurs sous contrat actif, contract_end jamais exposé ; cleanup OK. Testé UI (admin, Playwright) : colonne Contrat, toggle coché par défaut (masque les 25 sans contrat), éditeur avec date confidentielle + section contrats + bouton « + Ajouter un contrat ». NB opérationnel : tant qu'aucun contrat réel n'est saisi, les agents voient une liste promoteurs VIDE (comportement voulu).
  • - 2026-08-27 : Fiche lead (dashboard doublons/assignations) : plus de rechargement inutile à la fermeture + position de défilement conservée. Demande Matt : sur le dashboard admin (assignations + doublons), ouvrir une fiche puis la refermer SANS rien toucher rechargeait la vue et faisait perdre le scroll (pénible pour comparer des doublons). frontend/js/app.js (openLeadDetail) : ajout d'un flag committed mis à true uniquement quand une sauvegarde serveur a réellement eu lieu (dans commitAll, après API.updateLead). onDetailClose flush l'auto-save en attente puis ne rappelle renderView() QUE si committed ; sinon retour immédiat (aucun reload). Quand un refresh a bien lieu, on capture/restaure .main.scrollTop autour du await renderView() pour rester au même endroit. L'auto-save reste inchangé (input→600ms, change→immédiat, flush à la fermeture). Cache-bust ?v=20260827nodupreload, SW moa-crm-v58-20260827nodupreload. Frontend only, pas de restart. node --check OK. Testé Playwright (admin, données réelles) : (1) ouvrir/fermer sans toucher → aucun re-render (élément enfant du #content toujours isConnected), scroll maintenu à 4000 ; (2) éditer les notes puis fermer → auto-save committé, re-render, scroll maintenu à 3500 ; (3) intégrité des données OK (l'édition de test a été proprement restaurée, aucune trace laissée).
  • - 2026-08-27 : Alertes WhatsApp d'assignation : repli automatique sur le WhatsApp connecté de l'agent. Contexte : quand un lead est assigné à un agent, le CRM lui envoie un WhatsApp (notifyAssignment), mais seulement si l'agent est résolu via resolveContactForUser (répertoire team_members, par lien user_id ou nom non-ambigu) ET qu'une instance expéditrice est connectée (pickSenderInstance). Constat (demande Matt, ex : Raphaël) : plusieurs agents absents du répertoire ne recevaient jamais l'alerte. backend/server.js : resolveContactForUser gagne un 3e repli = le number de la PROPRE session WhatsApp open de l'agent (wa_sessions), donc tout agent qui appaire son WhatsApp devient automatiquement joignable, sans saisie manuelle. node --check OK, moa-crm.service redémarré. État au 2026-08-27 : joignables (dans le répertoire) = Alexis, Facundo, Gia, Lucas, Mathis, Monica, Vsevolod ; manquants (à ajouter au répertoire OU à faire appairer leur WhatsApp) = Raphaël, Antonella, Manuel, Thiago, Gary. En attente des numéros de Matt pour compléter le répertoire.
  • - 2026-08-27 : Nouveau rôle assistant (Valentina) : accès CRM restreint au SEUL tableau des conditions commerciales promoteurs, en édition. Demande Matt : que le tableau promoteur soit modifiable par les admins ET son assistante, à qui il faut créer un compte (elle a déjà un compte Yacaré). Compte créé : valentina@moa-realestate.com / MoaCRM2026! (temporaire, must_change_password=1), role='assistant', name "Valentina" (id ftECGPNhCVo0ULUZ). BACKEND (server.js+db.js) : ajout de 'assistant' à ROLES ; nouveau garde requirePromoterEditor (= role admin OU assistant) posé sur les 4 endpoints promoter-terms (GET/POST/PATCH/DELETE) à la place de requireAdmin → l'assistante lit ET écrit ce tableau, comme un admin. resolveFeatures renvoie tous les onglets agent à false pour un assistant ; waAllowed exclut le rôle assistant (pas de WhatsApp même avec WA_ALLOW_ALL=1). Tout le reste (/api/promoteurs annuaire Airtable, dashboard, leads, ventes...) reste requireAdmin/coord → 403 pour l'assistante. FRONTEND (app.js) : helper isAssistant(), defaultView()→promoteurs pour l'assistante, applyGating force un seul onglet visible (Promoteurs), navigate() la cantonne à promoteurs, dispatch renderView → nouveau renderPromoteursAssistant(c) = le panneau ÉDITABLE renderPromoterTermsPanel (le même que les admins) SANS l'annuaire admin (scrap Airtable, contacts WhatsApp des promoteurs). i18n ptasst.intro (ES/FR/EN). Cache-bust ?v=20260827promasst, SW moa-crm-v57-20260827promasst. moa-crm.service redémarré, node --check OK. Testé bout-en-bout (API : login Valentina, GET/POST/PATCH/DELETE promoter-terms = 200, dashboard/promoteurs/admin-feature-flags = 403, wa_enabled:false, features tout false ; puis navigateur Playwright : onboarding 1re connexion OK (choix langue + changement mot de passe), un seul onglet "Promotores", tableau éditable avec bouton "+" et 25 lignes/boutons d'édition ; compte remis à l'état 1re connexion après test). Admin/agent inchangés (seule une nouvelle branche assistant ajoutée aux dispatch).
  • - 2026-08-25 : Module WhatsApp OUVERT À TOUS LES COMPTES du CRM (avant : admins + liste blanche). Demande Matt : que tous les agents connectent leur propre WhatsApp pour commencer à ingérer leurs conversations (connaissance sur leurs leads). Avant, waAllowed(u) = role==='admin' || email ∈ WA_ALLOWED_EMAILS (par défaut Monica ; puis Alexis, Gia). Correctif backend (server.js, une seule fonction) : nouveau flag WA_ALLOW_ALL (défaut 1, surchargeable WA_ALLOW_ALL=0 dans backend/.env pour revenir à la liste blanche), waAllowed(u) = !!u && (WA_ALLOW_ALL || role==='admin' || email ∈ WA_ALLOWED_EMAILS). Un seul point de vérité : waAllowed pilote à la fois /api/whatsapp/status, requireWa (connect/logout) ET publicUser.wa_enabled (affichage de l'onglet WhatsApp côté front), donc le changement se propage partout. Rappel archi : chaque compte a sa PROPRE instance Evolution (moacrm_<userId>), ses conversations rattachées à son user_id, en RÉCEPTION SEULE (aucun envoi depuis le CRM, anti-ban) ; la session WA se crée à la 1re connexion (scan du QR), rien n'est connecté automatiquement. Pas de régression sur « Contacts » : cette vue (team) a été passée admin-only le 2026-08-24 (gating séparé team: admin front + requireAdmin back), donc ouvrir WhatsApp ne la ré-expose PAS aux agents. node --check OK, moa-crm.service redémarré (health 200). Vérifié (token minté par uid pour chaque user) : les 14 comptes (2 admins, 1 coordinateur, 11 agents) renvoient /api/me wa_enabled: true. Il reste à chaque agent à scanner son QR dans l'onglet WhatsApp. WA_ALLOWED_EMAILS conservé dans .env (inoffensif, sert seulement si on repasse WA_ALLOW_ALL=0).
  • - 2026-08-24 : Supervision ADMIN des ventes + commissions MOA (lecture seule, source = app moa-ops). Demande Matt : voir dans le CRM, côté admin, l'état des ventes et des commissions pilotées dans l'app séparée moa-ops (https://ventes-moa.panelbay.com, backend 127.0.0.1:8338), SANS dupliquer la logique. Tout est additif et lecture seule. Backend (server.js, nouveau bloc avant // ---- leads ----) : lecteur readAiosEnv(name) qui lit MOA_OPS_INTERNAL_KEY depuis /root/aios/.env (jamais hardcodée), constante MOA_OPS_URL (défaut http://127.0.0.1:8338, surchargeable par env), helper moaOpsGet(path) (fetch global Node 18+, en-tête X-Internal-Key, AbortController timeout 12s, ne jette jamais → {ok,status,data}), et deux routes auth, requireAdmin : GET /api/admin/moa-ventes (relaie /api/ventes/board + counts/statusColor/statutOptions de /api/ventes/meta) et GET /api/admin/moa-commissions (relaie /api/commissions). En cas d'erreur moa-ops, réponse 200 avec {error, rows:[]} pour que le CRM dégrade proprement (jamais de 500). Aucun endpoint d'écriture/paiement ici (l'édition reste dans moa-ops). Routes existantes/auth non touchées. API (api.js) : adminMoaVentes(), adminMoaCommissions(). Frontend (app.js) : deux vues admin-only ventesmoa + commissions (ajoutées à navLbl, showFor en admin, titles, garde-fou dans navigate, dispatch renderView gardé par isAdmin(), new-btn masqué dessus) ; renderVentesMoa (bouton « Ouvrir le tableau ops » → ventes-moa.panelbay.com nouvel onglet ; recherche + filtre statut ; table unité/projet, client, agent, prix payé, pill statut coloré via statusColor, entrega, commission, date réception, affilié, devise) et renderCommissions (cartes stats : nb à payer + restant par devise USD et PYG affichés SÉPARÉMENT, jamais additionnés + payé ; onglets À payer/Payées/À compléter/Toutes + recherche ; table unité/projet, agent, affilié, commission, taux %, payable, payé, restant, pill statut ; bouton « Gérer les paiements » → ventes-moa.panelbay.com/commissions). Montants dans leur devise ($ USD / ₲ PYG via money()), jamais convertis. Helper deaccent (recherche insensible aux accents) + opsMoney/opsStatusPill ajoutés. Entrées nav dans index.html (icônes ◆ / %). i18n ES/FR/EN : nav.ventesmoa/nav.commissions + blocs vm.*/cm.*. Testé sur instance temporaire (PORT=8399, token admin minté) : /api/admin/moa-commissions = 77 lignes + totaux (restant_par_devise {PYG:9 912 762, USD:52 440}, 42 à payer, 16 à compléter) ; /api/admin/moa-ventes = 116 lignes + counts par statut ; 401 sans token ; vues existantes intactes (/api/meta, /api/dashboard = 200). Playwright (admin) : les 2 entrées nav apparaissent, Commissions rend 4 cartes (devises séparées) + 42 lignes + onglets (À compléter = 16, table à 5 colonnes), Ventes MOA rend 116 lignes + filtre statut, Dashboard toujours OK, 0 erreur console. node --check OK (4 fichiers). NB : la clé interne doit être présente dans /root/aios/.env (MOA_OPS_INTERNAL_KEY, partagée avec moa-ops). ⚠ Incident during test : un pkill -f "node server.js" de nettoyage a aussi tué les process de prod moa-crm+moa-ops (mêmes argv) ; les deux services ont été immédiatement relancés (systemctl start, health 200) — donc la prod moa-crm tourne DÉJÀ avec cette version (revue possible en live).
  • - 2026-08-24 : Score de closing recentré sur l'ENGAGEMENT seul (sans qualification) pour les leads à assigner. Suite du barème pondéré : constat (Matt) que pour les leads à assigner (que personne n'a encore appelés) on a très peu d'infos et quasi jamais le budget (82% du pool en "?"), donc scorer le budget/capacité enterrait injustement des leads très intéressés mais pas encore qualifiés (ex. Dyaneley, investisseuse jointe au tél + projets envoyés, plafonnée à 37 par l'ancien barème). backend/close_scorer.mjs : la RUBRIC passe de 6 critères à 4 critères d'engagement uniquement, renormalisés sur 100 : Intérêt montré /50 (niveau le plus avancé atteint), Objectif /20 (investissement fait MONTER, sans pénaliser d'office "vivre sur place"), Délai /15, Qualité des échanges /15. Le budget et la capacité ne rapportent plus de points : s'ils sont fournis ils ne servent que de CONTEXTE pour juger l'intérêt/l'objectif. Label inchangé (hot 70-100 / warm 40-69 / cold 1-39), règle "?" conservée (aucune info d'engagement → null / unknown). close_reason = détail des points (ex. "Int29 Obj18 Dél11 Éch9 : demande le plan de paiement, projet locatif"), visible en tooltip. Validé sur un lot de 6 (sommes correctes : Dyaneley 37→61, "?" préservé). Re-scoring complet des ~1069 leads non assignés relancé en tâche de fond (close_scorer.mjs --all). Cron 20 min (incrémental) inchangé. Aucun changement de schéma ni de front. NB : le barème /100 précédent (Intérêt/35 + Budget/20 + Délai/15 + Capacité/10 + Objectif/10 + Échanges/10) est remplacé, pas cumulé.
  • - 2026-08-24 : Bloc Prioritaires : seuil relevé à close_score >= 60. Demande Matt : retirer des prioritaires tous ceux sous 60%. server.js (endpoint /api/dashboard, priorityList) : ajout AND close_score >= 60 à la requête (avant : close_label IN ('hot','warm') = tout dès 40). Les priorityCounts (hot/warm) et le bloc frontend s'ajustent automatiquement (pas de changement front). Effet vérifié : le pool prioritaire passe de 90 à 17 (73 tièdes 40-59 retirés), score minimum = 60. Service redémarré, node --check OK. NB : le filtre manuel « % closing min. » reste dispo par-dessus pour affiner davantage.
  • - 2026-08-24 : Filtres « à assigner » : ajout d'un seuil de certitude langue + d'un % closing minimum. Demande Matt : pouvoir choisir une langue AVEC un % de certitude minimum, et un % de closing minimum. Étendu le helper leadFilterBar (app.js) : deux nouveaux <select> de seuils — % closing min. (closeMin, options ≥40/50/60/70/80/90 ; n'apparaît que si des leads sont scorés) et Certitude langue min. (langMin, options ≥50/60/70/80/90). Logique dans matches : closeMin → close_score != null && close_score >= seuil ; certitude langue → une entrée langue satisfait le seuil si elle est certain (100%) ou confidence >= seuil ; si une langue est choisie, le lead doit avoir CETTE langue au-dessus du seuil ; si seul le seuil est posé, au moins une langue doit l'atteindre. Se combine avec tous les filtres existants (source, température, étape) + recherche + tri + sélection groupée, dans les 3 blocs. i18n flt.closeMin/flt.langMin (ES/FR/EN). Cache-bust i18n.js/app.js ?v=20260824filters2, SW moa-crm-v44-20260824filters2. node --check OK, pas de restart. Testé (navigateur admin, bloc courant 59 leads) : % closing ≥ 70 → 7 ; certitude langue ≥ 80 → 13 ; combiné Anglais + certitude ≥ 90 → 7 ; reset → 59.
  • - 2026-08-24 : Filtres sur les listes « à assigner ». Demande Matt : pouvoir filtrer les leads à assigner. Nouveau helper partagé leadFilterBar(list, onChange, {temperature}) (app.js) : construit une barre de <select> dont les options viennent des valeurs RÉELLEMENT présentes dans la liste (chaque select n'apparaît que s'il y a >1 valeur) — Source, Température (hot/warm/cold/?), Langue (drapeau+nom via LANG_FLAG/LANG_NAME), Étape (stageLabel) ; renvoie {row, matches(lead)}. Intégré aux 3 blocs du dashboard : (1) Prioritaires (sans le select Température, déjà géré par les puces chaud/tiède), (2) Leads à assigner courants, (3) Lots d'import Odoo. Les filtres se combinent avec la recherche texte, le tri colonnes et la sélection/assignation groupée existants (le prédicat flt.matches est ajouté au matches/paint de chaque bloc). i18n ES/FR/EN (flt.*), CSS .flt-row/.flt-sel. Client-side (sur les leads déjà chargés ; le bloc courant reste plafonné à 100). Cache-bust styles.css/i18n.js/app.js ?v=20260824filters, SW moa-crm-v43-20260824filters. node --check OK, pas de restart (statique). Testé (navigateur admin) : les 3 barres s'affichent avec les bonnes options ; filtrer « Température = tiède » sur le bloc courant passe de 59 à 14 lignes, reset → 59.
  • - 2026-08-24 : Assignation des leads facilitée : quick-assign par ligne + sélection multiple + assignation groupée. Demande Matt : assigner sans ouvrir la fiche, et pouvoir cocher plusieurs prospects au fil de l'eau puis les assigner tous d'un coup à une personne. Backend (server.js) : nouvel endpoint POST /api/leads/bulk-assign (requireAdmin) — body {ids:[], assignee_id, force}. Valide l'agent (existe, non archivé), applique le même garde-fou doublon que le PATCH (409 duplicate_assign + liste des conflits si un lead est un doublon FORT email/tél déjà assigné à un AUTRE agent, sauf force:true), met à jour en transaction, trace une activité « Assigné à X (assignation groupée) » par lead, et envoie une SEULE alerte WhatsApp récapitulative à l'agent (liste des leads, pas N messages ; best-effort via resolveContactForUser+pickSenderInstance). API (api.js) : bulkAssign(ids, assignee_id, force). Frontend (app.js) : helpers partagés par les 3 blocs « à assigner » (Prioritaires, courants, lots d'import) — selCheckbox (case par ligne, n'ouvre pas la fiche), quickAssignCell (menu déroulant « Assigner à… » qui assigne aussitôt le lead seul), selectAllTh (case d'en-tête = tout cocher dans CE tableau, via closest('table')), assignLeads(ids,agent,force) (gère le 409 doublon → confirm → renvoi force:true, puis renderView()), et barre flottante buildAssignBar (sticky bas, visible dès 1 lead coché : compteur + choix agent + « Assigner la sélection » + « Tout désélectionner »). Sélection persistante entre re-rendus (Map assignSel, ids). Chaque bloc gagne une colonne case (gauche) + colonne « Assigner à » (droite, remplace le bouton qui ouvrait la fiche). Réservé aux admins (dashboard admin-only + requireAdmin). i18n ES/FR/EN (assign.*), CSS (.assign-bar, .qa-sel, .sel-cb/.sel-all). Cache-bust styles.css/i18n.js/api.js/app.js ?v=20260824assign, SW moa-crm-v42-20260824assign. node --check OK (4 fichiers), service redémarré. Testé de bout en bout (token admin) : 400 sans agent, assignation groupée de 2 leads → assigned:2 vers Gia, vérifié en base, puis réverté (leads remis LIBRES + notes de test supprimées) pour préserver le pool « À réassigner ». Frontend servi à jour vérifié.
  • - 2026-08-24 : Dates affichées au format européen (JJ/MM/AAAA) partout. Demande Matt : format jour-mois-année. Constat : les helpers d'affichage fmtDate, fmtDayShort et fmtDay (app.js) étaient DÉJÀ en fr-FR {day:'2-digit',month:'2-digit',year:'numeric'} (donc JJ/MM/AAAA) ; le seul point non conforme était les blocs « À assigner » / « Prioritaires » / « Import Odoo » du dashboard qui, en repli (pas de first_contact_at), affichaient la date BRUTE ISO via (l.created_at||'').slice(0,10) → « 2026-08-24 ». Correctifs (app.js) : (1) fmtDayShort rendu robuste (slice sur les 10 premiers caractères → accepte aussi bien « AAAA-MM-JJ » qu'un timestamp « AAAA-MM-JJ hh:mm:ss » qui, sinon, cassait le new Date(d+'T00:00:00') et renvoyait le brut). (2) Les 3 replis passent de .slice(0,10) à fmtDayShort(l.first_contact_at || l.created_at) → JJ/MM/AAAA. Les <input type="date"> et isoLocal restent en ISO (valeur technique HTML requise, l'affichage du champ est localisé par le navigateur). Cache-bust app.js ?v=20260824dates, SW moa-crm-v41-20260824dates. node --check OK, frontend servi à jour vérifié. Pas de restart (statique).
  • - 2026-08-24 : Pipeline : zone de dépôt du drag & drop élargie à toute la colonne. Demande Matt : déplacer un lead d'une colonne à l'autre était pénible car le drop ne marchait que sur une petite zone. Cause : les handlers dragover/drop étaient déjà sur toute la colonne (.col), MAIS en CSS le board avait align-items:flex-start et .col n'avait pas de min-height → une colonne peu remplie était courte, donc la cible visible/cliquable était minuscule. Correctif CSS (styles.css) : .board → align-items:stretch et .col → ajout min-height:calc(100vh - 190px) (colonnes à pleine hauteur et égales, .col-body{flex:1} remplit le reste → toute la colonne devient une zone de dépôt, même vide). Correctif JS (app.js renderPipeline) : dragover pose dropEffect='move' (curseur correct), et dragleave ne retire le surlignage .drop que si on quitte réellement la colonne (!col.contains(e.relatedTarget)) au lieu de clignoter en survolant les cartes filles. Aucun changement backend (la logique de drop et PATCH stage est inchangée). Cache-bust styles.css/app.js ?v=20260824pipe, SW moa-crm-v40-20260824pipe. node --check OK, frontend servi à jour vérifié (curl). Pas de restart (fichiers statiques servis frais).
  • - 2026-08-24 : Onglet « Contacts » (annuaire équipe, vue team) réservé aux ADMINS. Demande Matt : les agents ne doivent plus y avoir accès. Avant : l'onglet + les routes team-* étaient gatés sur wa_enabled, donc tout agent avec WhatsApp (Monica, Alexis, Gia) le voyait. Correctif : (1) Frontend (app.js) : applyGating → team: admin (au lieu de wa_enabled), + redirection défensive dans navigate (if (v === 'team' && !isAdmin()) v = 'ventas'). (2) Backend (server.js) : les 6 routes GET/POST/PATCH/DELETE /api/team-contacts, GET /api/team-inbox, GET /api/team-thread passent de requireWa à requireAdmin (les admins étant wa_enabled, la fonctionnalité leur est préservée). L'onglet WhatsApp perso des agents n'est PAS touché (routes /api/whatsapp/* restent en requireWa). Cache-bust app.js ?v=20260824contacts, SW moa-crm-v39-20260824contacts. node --check OK, service redémarré. Vérifié : token agent (Gia) → /api/team-inbox et /api/team-contacts = 403, /api/whatsapp/inbox = 200 (WhatsApp perso conservé) ; app.js servi contient team: admin + le garde.
  • - 2026-08-24 : Archivage d'utilisateur (agent qui quitte) + réassignation des leads d'Abel Gaona. Demande Matt : Abel quitte l'agence → archiver sa fiche SANS la supprimer, et remettre tous ses leads (actifs ET perdus) dans les leads à réassigner. (A) Nouveau mécanisme d'archivage réutilisable (il n'en existait aucun) : (1) DB (db.js) migration additive users : archived INTEGER DEFAULT 0, archived_at TEXT. (2) Backend (server.js) : gate dans auth (une session dont le user est archived → 401 « Compte archivé ») ET dans /api/login (403), donc un archivé ne peut plus se connecter et ses sessions ouvertes tombent ; publicUser expose archived/archived_at ; /api/users trie les archivés en fin ; endpoints admin POST /api/users/:id/archive (refuse d'archiver un admin = anti-lockout ; révoque ses clés API ; renvoie leadsStillAssigned en garde-fou ; NE touche PAS aux leads, réattribution = action séparée) et POST /api/users/:id/unarchive (réversible). (3) Frontend (app.js renderAgents) : agents archivés grisés dans une section « Agents archivés » en bas, badge « archivé », bouton Archiver (avec confirm) sur les agents actifs / Réactiver sur les archivés ; le menu d'assignation exclut les archivés (assigneeOpts filtré, sauf s'ils sont l'assigné courant d'une fiche). (4) API (api.js) archiveUser/unarchiveUser. (5) i18n (i18n.js) ES/FR/EN ag.archive*. (6) CSS .agent-archived + .ag-arch-badge. (B) Abel archivé via l'endpoint (twKZ9Vc5e715b5PL, vérifié : archived_at posé, son token → 401, clés API révoquées). (C) Ses 8 leads libérés et remis à réassigner (script transactionnel, tag « À réassigner » + note d'activité sur chaque) : les 4 actifs (Intéressé) → assignee_id=NULL, étape conservée ; les 4 perdus (motif « Not Qualified (Odoo) ») → assignee_id=NULL + réactivés en « Sans contact » (lost_reason effacé) pour être retravaillés. intake_source=odoo_import (≠ odoo_migration_*) donc ils apparaissent dans le bloc « À assigner » standard + le bloc Prioritaires une fois scorés. Cache-bust styles.css/i18n.js/api.js/app.js ?v=20260824archive, SW moa-crm-v38-20260824archive. node --check OK (5 fichiers), moa-crm.service redémarré (migration appliquée : colonnes archived/archived_at présentes). NB : Abel reste dans FULL_AGENT_EMAILS/WA_ALLOWED_EMAILS du .env (inoffensif, le gate d'archivage prime sur l'allowlist) ; à nettoyer si besoin.
  • - 2026-08-24 : Module WhatsApp activé pour Gia Ferreira (contact.giafferreira@gmail.com). Demande Matt (suite de l'ouverture d'accès CRM) : lui donner aussi l'accès pour connecter WhatsApp. Ajout de son email à WA_ALLOWED_EMAILS (backend/.env, était Monica + Alexis → + Gia ; backup .env.bak-preGiaWa-*), moa-crm.service redémarré. Vérifié (token minté) : /api/me renvoie wa_enabled: true → ses onglets WhatsApp et Équipe s'affichent (applyGating sur wa_enabled). Il lui reste à connecter son instance côté écran (scan du QR Evolution) ; l'instance sera moacrm_3JEmotvwGEqMFCu4. Rappel mécanisme : waAllowed(u) = role==='admin' || email ∈ WA_ALLOWED_EMAILS ; le flag n'ouvre que l'accès au module, la session WhatsApp elle-même se crée à la 1re connexion QR.
  • - 2026-08-24 : Accès CRM complet ouvert à Gia Ferreira (Marly Giannina Fernández Ferreira, contact.giafferreira@gmail.com). Demande Matt : elle n'avait « aucun accès ». Constat : c'était la SEULE agente absente de l'allowlist FULL_AGENT_EMAILS (backend/.env) alors que les 11 autres agents + Gary y étaient. Sans « full access », un agent voit ses vues Pipeline / Prospects / Tâches bloquées en écran « Coming soon » (renderComingSoon, gate fullAgent() dans app.js) ; il ne lui restait que Ventes / Assistant / Biens / Promoteurs. Correctif : ajout de son email en fin de FULL_AGENT_EMAILS (backup .env.bak-preGiaFull-*), moa-crm.service redémarré. Vérifié (token minté pour son compte) : /api/me renvoie désormais full_access: true, et elle voit bien ses 10 leads déjà assignés dans son book. NB technique : le vrai levier est l'allowlist d'emails FULL_AGENT_EMAILS (helper fullAgent(u) = role==='admin' || email ∈ allowlist) ; la colonne DB users.full_access est legacy/inutilisée par le code (tous les full agents ont full_access=0 en base), ne pas s'y fier. Périmètre des données inchangé : comme tout agent non-admin, /api/leads reste scopé à son propre book (ownsLead) ; « full access » débloque les VUES agent, pas la visibilité sur les leads des autres. Le module WhatsApp reste séparé (WA_AGENT_EMAILS/waAllowed, opt-in par agent avec connexion d'instance) : wa_enabled: false pour Gia, non demandé ici.
  • - 2026-08-24 : Dashboard : nouveau bloc « 🔥 Prioritaires à assigner aujourd'hui ». Demande Matt : remonter en tête les leads à assigner les plus chauds pour les traiter/assigner dans la journée. Constat : le scoring IA (close_scorer.mjs, champs close_label/close_score) marquait bien 120 leads non assignés en hot/warm, MAIS 99 d'entre eux étaient enfouis dans le lot d'import Odoo (intake_source=odoo_migration_*), volontairement exclu du bloc « à assigner » courant, et les 21 restants étaient noyés dans les 100 récents triés par date → invisibles en pratique. (1) Backend (server.js, endpoint /api/dashboard) : ajout priorityList = TOUS les leads assignee_id IS NULL + stage NOT IN (gagne,perdu) + close_label IN (hot,warm), lots d'import Odoo INCLUS (un lead chaud reste chaud où qu'il soit rangé), triés CASE hot=0/warm=1 puis close_score DESC. Plus priorityCounts {hot,warm}. Réutilise UNASSIGNED_COLS + shapeUnassigned (langues inférées, dup). (2) Frontend (app.js) : renderPriorityPanel(c,d) appelée en tête de renderDashboard, panneau .panel-priority avec titre + compteurs, sous-titre, chips filtre (Tous / 🔥 Forte proba / Proba moyenne), recherche, table colonnes % closing (chip) · Prospect · Pourquoi (close_reason) · Source · Contact · Langue · Date · [Assigner]. Réutilise closeChip/infoBadge/dupBadge/langCell/openLeadDetail. (3) i18n (i18n.js) ES/FR/EN : dash.priorityPanel, dash.priorityEmpty, dash.prioritySub, dash.priorityAll, dash.priorityWhy. (4) CSS (styles.css) : .panel-priority (bord gauche + dégradé accent --warn, titre orangé). Cache-bust styles.css/i18n.js/app.js ?v=20260824priority, SW moa-crm-v37-20260824priority. node --check OK (3 fichiers), moa-crm.service redémarré. Vérifié live (token admin minté + Playwright) : endpoint renvoie {hot:27, warm:93} / 120 items, et le bloc s'affiche en tête du dashboard (« 🔥 Prioritaires à assigner aujourd'hui (120) », Shane Meadahl 88 % en 1re ligne avec sa raison). Prochaine étape possible : bouton d'assignation en masse depuis ce bloc (pour l'instant chaque ligne ouvre la fiche pour choisir l'agent).
  • - 2026-08-24 : Formulaire de vente : option « Baulera incluse » + report optimisé dans Airtable. Demande Matt : quand un agent déclare une vente, le formulaire ne permettait pas d'indiquer une baulera (débarras/rangement) incluse ; elle doit aussi remonter proprement dans Airtable. Implémenté en miroir exact du stationnement (cochera/estacionamiento) : (1) DB (db.js) migration additive ventas : baulera INTEGER DEFAULT 0, baulera_numero TEXT, baulera_precio_usd REAL. (2) Backend (server.js) : ajout aux VENTA_FORM_FIELDS (donc à l'INSERT), baulera en booléen, baulera_precio_usd en numérique. Champs optionnels (une baulera peut être incluse sans surcoût ou pas encore attribuée → « potentielle »), pas de validation dure. (3) Formulaire (app.js, section « Unité ») : toggle « Baulera incluse ? » sous le stationnement, qui déploie baulera_numero (facultatif) + baulera_precio_usd (facultatif, vide = incluse) ; visibilité conditionnelle restaurée avec le brouillon ; ligne ajoutée au récap de validation. (4) i18n (i18n.js) ES/FR/EN : vf.baulera, vf.baulera_numero, vf.baulera_precio_usd, vf.baulera_incluida. (5) Airtable (airtable_sync.js) : mapping vers deux champs DÉDIÉS créés dans la table « Unités vendues » (base Moa appSVJQULMJNXy9Hl / tblfIJdn0MB5dHDJ4) — Baulera (case à cocher fld84s0Xzq2hkcyiF, filtrable) et Baulera détail (texte fldYgwN76noeKyXLA : « N° X · USD Y » ou « incluse »). Pas noyé dans les Commentaires. NB découvert au passage : la cochera/stationnement n'est elle non plus PAS encore synchronisée vers Airtable (buildFields ne la mappait pas) — proposé à Matt de l'ajouter sur le même modèle. Cache-bust i18n.js/app.js ?v=20260824baulera, SW moa-crm-v36-20260824baulera. node --check OK sur les 5 fichiers, moa-crm.service redémarré (migration appliquée : 3 colonnes présentes), frontend servi à jour (toggle + i18n vérifiés via curl). Vérif UI live non faite : le login admin documenté (matt@moarealestate.com/moacrm2026) renvoie 401 (mot de passe visiblement changé à la 1re connexion), à confirmer par Matt côté écran. Le champ est un miroir strict du bloc cochera (qui fonctionne).
  • - 2026-08-22 : API REST par clé pour les agents (/api/v1) : piloter SON portefeuille depuis un outil externe / son Claude. Demande Matt : une API pour que chaque agent fasse des appels (avec son Claude, un script...) et optimise son workflow. Choix Matt : API REST à clés (pas MCP seul ; le MCP read-only existant reste dispo en complément). Implémenté : (1) Table api_keys (db.js) — 1 clé active/agent, seul le hash SHA-256 est stocké (jamais le secret en clair), + prefix affichable, last_used_at, revoked_at. (2) Middleware apiKeyAuth (server.js) : lit Authorization: Bearer ou X-API-Key, résout l'agent, pose req.user=req.acting=agent → scoping identique au JWT (ownsLead/ensureLeadAccess : admin voit tout, agent = son book). (3) Routeur /api/v1 monté avant le catch-all SPA, rate-limit 120/min/clé. Lecture : me, meta, leads (filtres stage/q/limit/offset), leads/:id (+activités), pipeline, agenda. Écriture bornée (choix Matt) : POST leads (créé assigné au porteur, intake_source=api), PATCH leads/:id (contact + stage validé + next_action...), POST leads/:id/activities (note), POST leads/:id/relance (avance/undo). Chaque changement d'étape/relance est tracé en activité (mention API). Volontairement absents : DELETE, réattribution à un autre agent, module ventes/prix. (4) Gestion des clés : self-serve GET|POST|DELETE /api/me/api-key (JWT), admin GET|POST /api/admin/api-keys + DELETE /api/admin/api-keys/:id, et CLI scripts/issue_api_key.js <email> [label] (clé affichée une seule fois). (5) Doc agents docs/API.md (endpoints, exemples curl, « utiliser depuis ton Claude », sécurité). Testé de bout en bout (clé jetable pour Raphaël Sterckx, révoquée après) : /me, 401 sans/mauvaise clé, /leads scopé (94 leads), /pipeline, création → PATCH stage → activité → relance, stage invalide → 400, et surtout accès à un lead d'un autre agent → 403 (GET/PATCH/activité). Lead de test supprimé, clé de test révoquée. node --check OK, moa-crm.service redémarré, route publique HTTPS vérifiée (https://crm-moa.panelbay.com/api/v1/me → 401 attendu). Restait comme prochaine étape MCP « outils d'écriture » : cette API REST couvre le besoin d'écriture ; le MCP peut rester read-only.
  • - 2026-08-22 : Endpoint PUBLIC POST /api/web-lead : les formulaires du site vitrine moa.panelbay.com alimentent directement le CRM. Demande Matt : brancher un formulaire « lead chaud » sur le site (fiche projet + page contact) qui crée le lead dans le CRM. Le site étant statique (aucune clé ne peut y vivre), l'endpoint est public mais défendu : rate-limit 12/h par IP (webLeadLimiter), honeypot website (rempli → 200 sans création), validation stricte (WhatsApp ou email requis), CORS restreint. Lead créé source=site_web, intake_source=web_form, assigné au coordinateur (Gary, role=coordinateur), dédoublonné email/tél (réutilise la logique de /api/leads/ingest : un doublon n'écrase rien, ajoute une activité « Nouvelle demande via le site web »). Budget mappé en budget_min/max, langue en language, typologie en unit_type, programme + message dans notes. Ajout https://moa.panelbay.com à CORS_ORIGINS dans backend/.env (le défaut du code l'inclut aussi en secours). Testé de bout en bout (curl : honeypot bloqué, 400 sans contact, création, dédoublonnage ; puis vrai navigateur Playwright sur la fiche Marena → lead créé assigné Gary avec note programme, nettoyé). Restart moa-crm.service fait, node --check OK. Site : voir CHANGELOG de MOA-refonte.
  • - 2026-08-21 : Bouton « Envoyer à Resident Paraguay » sur la fiche client (transfert croisé vers le CRM de Younes). Demande Matt : un bouton avec le logo RP sur la fiche qui balance une copie du lead dans le CRM Resident Paraguay (apps/rp-crm). BACKEND (server.js) : POST /api/leads/:lid/push-rp (auth + ensureLeadAccess) construit un payload {name,email,phone,source:'moa_crm',note,raw} (note = « Transféré depuis le CRM MOA par <user> le <date>, étape, pays/langue/budget + notes MOA » ; raw porte moa_lead_id/odoo_lead_id/transferred_by) et POST vers le endpoint d'ingestion RP RP_CRM_INGEST_URL (défaut http://127.0.0.1:8332/api/ingest) avec header x-api-key: RP_CRM_INGEST_KEY (les deux dans backend/.env). Idempotent : le cœur ingest() côté RP déduplique (email > tél > nom), donc recliquer n'ajoute pas de doublon (deduped:true). Trace : colonne leads.rp_pushed_at (migration db.js) + activité « ↗ Copie envoyée au CRM Resident Paraguay ». FRONTEND (app.js) : bouton rpBtn (logo /icons/rp-logo.png) dans l'en-tête de la fiche à côté du bouton WhatsApp, confirmation avant envoi, état « Envoyé ✓ » persistant (via rp_pushed_at exposé par shapeLead), renvoi possible sans doublon. api.js pushToRp, i18n ld.pushRp* (ES/FR/EN), CSS .rp-push-btn. Côté RP : source moa_crm ajoutée à SOURCES (rp-crm/backend/db.js) pour la traçabilité. Clé RP = INGEST_API_KEY de apps/rp-crm/.env. Cache-bust ?v=20260821rppush, SW moa-crm-v35-20260821rppush. Restart des 2 backends fait, node --check OK partout. Testé de bout en bout (lead jetable, JWT admin forgé) : push #1 → deduped:false + prospect créé côté RP (source=moa_crm, status=nouveau) ; push #2 → deduped:true même rp_id ; test nettoyé des deux côtés.
  • - 2026-08-21 : Recherche + tableau séparé pour l'import Odoo, et neutralisation des placeholders « No name » dans la détection de doublons. (a) Migration de 1045 leads Odoo (compte maison MOA + sans commercial, hors Won/Lost) vers le CRM : tag import-odoo-2026-08-21 + intake_source=odoo_migration_20260821, résumé de contexte par lead dans les notes, historique d'assignation Odoo (33 déjà passés par un agent) ajouté. Dashboard : ces leads sortent du panneau « Leads à assigner » courant (reste à 51) et s'affichent dans un tableau séparé « Leads importés d'Odoo » (cherchable, affichage progressif 60/60) via importBatches dans /api/dashboard. (b) Champ de recherche ajouté au panneau « Leads à assigner » courant. (c) Doublons : les noms placeholder (« No name », « Sans nom », « Sin nombre »…) ne rapprochent plus deux fiches par nom/patronyme (isPlaceholderNameD neutralise nameKeyD+surnameKeyD) ; seul email/téléphone les relie. Cache-bust successifs ?v=20260821odoomig → search, SW v33→v34.
  • - 2026-08-21 : Table « Conditions promoteurs » rendue visible aux AGENTS (version filtrée, sans donnée sensible). Demande Matt : le tableau promoteurs (jusque-là admin only) doit être accessible aux agents, mais SANS aucune donnée admin/sensible. Solution : onglet Promoteurs désormais visible pour tous (gating showFor.promoteurs = true, retiré du redirect non-admin) ; pour un non-admin, renderPromoteursAgent affiche une table lecture seule = Promoteur / Drive / Moyens de paiement / Crypto. La commission MOA (marge), les notes internes et les flags de complétude ne sont JAMAIS exposés : filtrage côté serveur via GET /api/promoter-terms/public (auth sans requireAdmin) qui ne SELECT que name,drive_url,payment_methods,crypto. Les endpoints d'édition (GET/POST/PATCH/DELETE /api/promoter-terms) restent requireAdmin. La vue admin (renderPromoteurs + panneau éditable + alerte dashboard) est inchangée. api.js promoterTermsPublic, i18n ptpub.* (ES/FR/EN). Cache-bust ?v=20260820agents, SW moa-crm-v30-20260820agents. Restart fait, node --check OK. Testé (token agent Facundo forgé) : /promoter-terms/public → 25 lignes, clés {name,drive_url,payment_methods,crypto} uniquement, zéro commission ; /promoter-terms (admin) → 403 ; vue navigateur agent : onglet visible, colonnes sans commission, mot « commission » absent de la page, lecture seule. Propagé aux agents connectés via l'auto-refresh (90 s). MAJ même jour : Matt a décidé que la commission MOA est OK à afficher aux agents → colonne commission ajoutée au SELECT de /promoter-terms/public et à renderPromoteursAgent (Promoteur / Commission / Drive / Paiement / Crypto). Seules les notes internes restent masquées côté agent. Vérifié : endpoint public renvoie {name,commission,drive_url,payment_methods,crypto}, jamais notes. Cache-bust ?v=20260821comm, SW moa-crm-v31-20260821comm.
  • - 2026-08-20 : Promoteur « Uno Tres » retiré (on ne travaille plus avec eux, demande Matt). Supprimé du catalogue (moa-catalog/catalog.db) : promoteur (id 279) + 9 projets (Torre Solana 1/2, Villa del Bosque, Casas del Bosque, Torre Narciso La Cuadrita, Tres Kandú, Torre Narciso, Iberus, Dúplex Dinex Terrazas) + 497 unités → la vue Biens (lecture live du catalogue) ne les montre plus (20 → 19 promoteurs). Supprimé aussi de promoter_terms (conditions commerciales, 26 → 25). Automatisations retirées : garde-fou anti-réingestion ajouté dans moa-catalog/scripts/ingest_tulugar.py (EXCLUDED_PROMOTEURS = {"uno tres"} + skip dans la boucle d'ingest — sinon le scrape tulugar quotidien recréerait le promoteur) ; parser dédié parse_unotres.py + son manifest archivés dans scripts/_retired/ ; entrées delivery_overrides.json « Uno Tres|… » retirées. Aucun lead n'avait d'unité Uno Tres attachée (0 dans lead_catalog_units), donc zéro impact prospect. Backups : catalog.db.bak-20260820-unotres, moa-crm.db.bak-20260820-unotres. Pas de restart (catalogue lu en direct). Annuaire Airtable Promoteurs (base Moa appSVJQULMJNXy9Hl, table tblJxCE7zvWFE6kx1) : fiche Uno Tres (recQCKqdl8aXGbKtM, contact Noelia garban) supprimée aussi (accord Matt). Vérifié : aucun projet Uno Tres dans « Projets Plan », aucune vente Uno Tres dans « Unités vendues » (historique préservé de toute façon). Uno Tres est donc retiré partout : catalogue, conditions, scraper (garde-fou), annuaire Airtable.
  • - 2026-08-20 : Auto-refresh sur nouvelle version déployée, sans déconnexion. Demande de Matt : que le CRM se recharge tout seul quand une mise à jour est déployée, sans que les agents soient déconnectés (avant : hard refresh manuel obligatoire pour voir un nouveau ?v=). Comme le token vit dans localStorage, un location.reload() garde la session (boot() refait /me via Bearer). Le SW est déjà network-first, donc un reload refetch les assets frais. BACKEND (server.js) : GET /api/version (public, léger) renvoie {version} = max des mtimes de index.html/styles.css/js/app.js/js/api.js/js/i18n.js/sw.js (change tout seul à chaque déploiement, aucun compteur à maintenir). FRONTEND (app.js) : setupVersionWatch() (lancé depuis afterAuth après startApp, une seule fois) mémorise la version au boot et sonde /api/version toutes les 90 s ; quand elle change, recharge immédiatement si l'agent ne fait rien (isSafeToReload = aucune .overlay ouverte + aucun INPUT/TEXTAREA/SELECT/contentEditable focus), sinon affiche une bannière « Nouvelle version disponible · Recharger » et recharge automatiquement dès qu'il est libre (retry 4 s) — bouton manuel aussi. i18n update.available/update.reload (ES/FR/EN), CSS .update-banner. Cache-bust ?v=20260820ver, SW moa-crm-v29-20260820ver. Restart backend fait, node --check server/app/i18n OK. Testé (Playwright, session admin) : /api/version répond ; touch d'un asset → version change ; page ouverte détecte la nouvelle version et verdict « recharge auto immédiate » ; session conservée après reload (pas de déconnexion). NB : ~15-22 agents × 1 requête/90 s = charge négligeable.
  • - 2026-08-19 (bugfix) : Fiche lead ↔ vue pipeline désynchronisées à la fermeture. Symptôme (Matt) : modifier un client dans la fiche puis fermer ne mettait pas à jour la vue pipeline derrière. Cause : seul le bouton « Fermer » (closeDetail) faisait flush de l'auto-save + renderView() ; fermer par clic sur le fond ou touche Échap appelait closeModal() directement, sans flush ni refresh → carte pipeline restée sur l'ancienne valeur (la donnée était pourtant bien en base). Fix (app.js, front only) : openModal(node, {onClose}) mémorise un handler de fermeture dans _modalOnClose, et closeModal() l'appelle quel que soit le chemin (bouton, fond, Échap). La fiche passe onClose: onDetailClose = flush de l'auto-save en attente puis renderView(). Ancienne closeDetail supprimée ; bouton « Fermer » et croix ✕ appellent désormais closeModal(). Garde-fou skipClose (mis à true avant fermeture pour la suppression et le rejet de brouillon, où re-sauver n'aurait pas de sens ou écraserait un brouillon rejeté). Cache-bust app.js ?v=20260819fix, SW moa-crm-v28-20260819fix. node --check OK, aucune référence morte à closeDetail. Fichier statique → pas de restart backend. À TESTER par Matt : éditer un lead dans la fiche, fermer par le fond/Échap, vérifier que la carte pipeline reflète le changement.
  • - 2026-08-19 (suite) : Réconciliation promoter_terms ↔ catalogue CRM. Croisement des 14 promoteurs importés du Sheet avec les promoteurs ayant des projets dans moa-catalog/catalog.db. 12 promoteurs présents au catalogue (avec projets) mais absents du tableau ont été ajoutés à promoter_terms (ABV, Alamo Desarrollos, Altius Group, Edificios Palmanova, Living Desarrollos, Pro Invest, Urban Domus, V Tower del Lago, Vierci Development, Vitrium, WA Investment Management S.A., Zuba), drive repris du catalogue quand dispo (Zuba), le reste vide (à renseigner), note d'origine posée. Mapping des variantes de nom géré (« Altea Desarrollos »↔« Altea/Altanova », « Veralta/Altacreo »↔« Veralta » = déjà présents, pas de doublon). Total 26 promoteurs, 25 incomplets. NB : 6 promoteurs du tableau n'ont PAS de projet au catalogue (Avanza, CASATUA, Cima Desarollos, Marena, Nativus, Perseverencia) : signés/annuaire mais non scrapés, ou nom différent, laissés en place.
  • - 2026-08-19 : Module « Conditions commerciales promoteurs » (import du Google Sheet recap, editable en app, ADMIN) + alerte de complétude sur le dashboard. Demande de Matt : afficher son tableau recap promoteurs (Sheet 1-DqvwHRI9CPvXZuQEkZ5VGEVp7ZCpAesNSpJp1d--jY, colonnes Name/Commission/Drive/Crypto/Metodo de pago) dans le CRM, source de vérité = importée dans le CRM (choix Matt, éditable en app, le Sheet devient obsolète). DB (db.js) : table promoter_terms (id,name,commission,to_confirm,drive_url,crypto,payment_methods,notes,created_at,updated_at). BACKEND (server.js, ADMIN) : GET /api/promoter-terms (renvoie {rows, summary} ; chaque row porte missing[] = champs requis vides parmi commission/drive_url/crypto/payment_methods, to_confirm_flag si la commission contient « to confirm », complete), POST (création), PATCH /:id (édition), DELETE /:id. Helper ptClean traite ./vide comme manquant. FRONTEND : vue Promoteurs enrichie d'un panneau renderPromoterTermsPanel (table éditable inline : + Ajouter, ✎ Éditer avec commission / crypto / moyens de paiement / drive / notes + checkbox « à confirmer » / Supprimer ; lignes incomplètes surlignées, chips « à remplir »/« à confirmer ») ; dashboard admin = panneau « Infos promoteurs à compléter » listant chaque promoteur incomplet avec les champs exacts manquants + bouton vers la vue Promoteurs. api.js (promoterTerms/promoterTermCreate/Update/Delete), i18n pt.* (ES/FR/EN), CSS .pt-* + .btn-danger. Import des 14 promoteurs du Sheet effectué (script one-shot, . nettoyés, « to confirm » détecté sur Petra Urbana). Restart fait, node --check OK (server/db/app/api/i18n), endpoint 401 sans token, 14 lignes en base. Constat de complétude : méthode de paiement manquante sur 13/14 (seul Civis complet), commission absente (Avanza, Uno Tres, Aregua Forest), drive absent (Cima, Aregua Forest), Aregua Forest entièrement vide. NB de structure : dans le Sheet source les colonnes Crypto et Metodo de pago étaient mélangées (valeur unique ambiguë sur la plupart des lignes) ; à clarifier lors de la saisie en app.
  • - 2026-08-17 : Tuto vidéo agent (ES) produit hors app (captures + build dans /root/workspace/crm-tuto/). Seule trace laissée dans l'app : colonne full_access ajoutée à la table users (INTEGER DEFAULT 0), NON lue par le serveur (l'accès complet reste piloté par la whitelist FULL_AGENT_EMAILS de backend/.env). Inoffensive, laissée en place. Le compte démo + leads fictifs ont été supprimés, la whitelist restaurée, service redémarré.
  • - 2026-08-13 (soir, +tard) — Assistant IA intégré au CRM (read-only, alternative au connecteur MCP). Contexte : le connecteur MCP (/mcp) est prouvé fonctionnel de bout en bout côté serveur (flux OAuth confidentiel client_secret_post rejoué en test = DCR 201 → authorize → login → /token 200 → appel MCP 200), mais claude.ai ne rappelle jamais /token après le retour du code pour l'environnement d'un agent (Monica) : 21 codes émis, 0 échangé, malgré Chrome incognito + compte Pro. Blocage 100% côté claude.ai, non corrigeable par nous. Solution retenue avec Matt : mettre l'IA dans le CRM plutôt que de brancher le Claude de chaque agent. BACKEND (server.js) : POST /api/assistant/ask (auth + rate-limit 15/min) ; scoping SERVEUR identique au reste (admin = tout le pipeline, sinon assignee_id = agent connecté via req.acting) ; charge jusqu'à 300 leads du portefeuille (triés par dernière activité) + répartition par étape, construit un prompt (règle : répondre UNIQUEMENT depuis ces données, langue de la question, concis/actionnable, ne rien inventer), et appelle le CLI claude -p en async (execFileAsync, timeout 120s) — abonnement, JAMAIS l'API Anthropic, zéro clé API, comme les crons IA. Renvoie {answer, scope}. FRONTEND : onglet « ✦ Assistant » (nav, visible pour tous), renderAssistant = mini-chat (message d'accueil + 3 suggestions cliquables, saisie Ctrl+Entrée, état « analyse… », réponses en white-space:pre-wrap), api.js aiAssistantAsk→assistantAsk, i18n nav.assistant + asst.* (ES/FR/EN), CSS .asst-*. Cache-bust ?v=20260813assistant (styles/i18n/api/app). Restart backend fait, node --check server/app/api/i18n OK. Testé en live (JWT agent forgé, agent Facundo 106 leads) : scoping {admin:false, leads:106} respecté, « répartition par étape » exacte, « 3 leads prioritaires à relancer » = réponse pertinente et datée (10-26s de latence, acceptable). Le service (root, /usr/bin/claude dans le PATH) exécute bien claude. NB : consomme le quota de l'abonnement Claude de Matt (partagé, comme les crons) ; mettre une limite par agent si l'usage grossit. Le connecteur MCP reste en place : un agent dont claude.ai fonctionne pourra toujours brancher son propre Claude. Reste possible (v2) : capacités d'ÉCRITURE scopées (créer tâche/note, changer étape) avec garde-fous.
  • - 2026-08-13 (soir) — Garde-fou doublon à l'ATTRIBUTION (confirmation obligatoire). Demande Matt (« que les leads à assigner passent aussi par la détection de doublon, sinon ça crée des soucis »). La détection s'appliquait déjà à la liste « à assigner » (badge ⚠ + bannière fiche) mais restait passive : rien n'empêchait d'assigner un lead qui double un lead déjà pris par un autre agent → deux agents sur la même personne. Ajout d'un garde-fou serveur (couvre TOUTES les voies d'attribution). BACKEND (server.js, PATCH /api/leads/:lid) : quand le patch assigne le lead (assignee_id non nul, différent de l'actuel) et qu'un doublon FORT (via email/phone, jamais name seul) est déjà assigné à un AUTRE agent et pas perdu, refus 409 {code:'duplicate_assign', conflicts:[{name,via,stage,assignee_name}]}. Pour outrepasser : renvoyer force:true ; l'attribution forcée est alors tracée en activité (« ⚠ Attribué à X malgré un doublon déjà assigné : … »). Cas autorisés sans friction : assigner au même agent que le doublon, désassigner, doublon par homonyme seul. FRONTEND (app.js, commitAll de la fiche) : intercepte le 409, confirmation listant les conflits + leur agent, ré-émet en force:true si confirmé (sinon remet l'agent précédent dans le select, sans re-déclencher de save). Le CTA « Assigner » du dashboard ouvre la même fiche → couvert. La fusion (/api/duplicates/merge) n'est pas impactée. Cache-bust app.js ?v=20260813dupassign (seul asset front modifié). Restart backend fait, node --check server/app OK. Testé live (leads jetables, JWT admin forgé, base laissée propre) : (1) autre agent sans force → 409 + conflicts corrects ; (2) force:true → 200 + activité tracée ; (3) même agent que le doublon → 200 (pas de faux positif).
  • - 2026-08-12 (soir, +tard³) — Import AUTOMATIQUE et récurrent des prospects de contact@moa-realestate.com (classement par Claude). Demande Matt : que ce soit désormais automatique. Nouveau poller sources/contact_moa_import.py (cron */30 * * * *, log sources/contact_moa_import.log, état sources/contact_moa_state.json = UID vus). Pipeline : (1) IMAP lecture seule, UID non-vus sur fenêtre --since 45 j ; (2) pré-filtre déterministe qui jette le bruit certain (DMARC, banque Atlas, expéditeurs spam connus, notifs auto, usurpation Meta/FB, thewanderinginvestor = leads Ladislas traités à part, mots-clés spam/phishing) et NE garde que les mails mentionnant l'immobilier (candidats) ; (3) classement par Claude (/usr/bin/claude -p, sortie JSON stricte) : vrai prospect (veut ACHETER / INVESTIR / LOUER / FAIRE ESTIMER un bien au Paraguay, demande brochure/prix/RDV) vs démarchage B2B / phishing / spam ; Claude fournit aussi le résumé FR ; (4) import des retenus via POST /api/leads/ingest (dédoublonnage email/tel côté serveur) avec source='Email contact', tag='Contact mail', note=résumé, first_contact_at=date de l'email ; (5) notification à Matt des nouveaux prospects. Robustesse : si Claude échoue, les candidats ne sont PAS marqués vus (réessai au prochain run) + notif debouncée ; le bruit pré-filtré est marqué vu immédiatement. Endpoint /api/leads/ingest étendu : accepte désormais first_contact_at (validé ^\d{4}-\d{2}-\d{2}) et l'insère. Vérifié : dry-run 60 j = 153 bruit / 7 candidats → Claude garde 6 vrais (Mariia, Shane, Dan Robinson [lead raté récupéré], Sonia, latte0605, Tony) + rejette le spam de domaine ; run réel = 1 créé (Dan Robinson) + 5 dédoublonnés ; probe ingest confirme first_contact_at stocké. python3 OK. NB : le poller Ladislas (ladislas_moa_import.py) reste séparé (formulaire WPForms structuré) ; ce poller-ci gère le tout-venant de la boîte contact.
  • - 2026-08-12 (soir, +tard²) — Import des prospects de contact@moa-realestate.com en « à assigner » + bouton info (ⓘ) avec résumé et date de premier contact. Demande Matt. Backend : db.js colonne additive leads.first_contact_at TEXT (date du 1er contact réel, distincte de created_at = date d'import). server.js : unassignedList du dashboard sélectionne désormais aussi notes + first_contact_at, LIMIT 30 → 100. Front (app.js) : infoBadge(l) (module scope, près de nextStepBadge) = pastille « i » ronde ; au survol/focus, showTip() (tooltip body-fixed, non rognée) affiche « Contact reçu le JJ/MM/AAAA » + la note (résumé de ce que veut le prospect). Ajoutée sur la carte pipeline (lc-top) ET sur chaque ligne de la liste « À assigner » du tableau de bord ; dans cette liste la colonne date affiche maintenant first_contact_at (vraie date de contact) au lieu de created_at. i18n card.received / card.infoAria (es/fr/en). CSS .lc-info + white-space:pre-line ajouté à .tip-pop (rend les sauts de ligne, améliore aussi le tooltip doublons). Cache-bust ?v=20260812info2. Import de données (script one-shot, backup backend/data/moa-crm.db.bak-2026-08-12-contactimport) : 33 prospects réels créés sans_contact, non assignés, source='Email contact', tag Contact mail, notes=résumé, first_contact_at=date de l'email, intake_source='contact_import' + activité de traçabilité. Dédoublonnage strict (email + tél 9 derniers) contre l'existant : 1 ignoré (Juan Quintanilla, déjà présent). Le spam (SEO, faux Meta, arnaques) et les rapports DMARC ont été exclus. Total non-assignés sans_contact : 41 → 74. Vérifié live (Playwright) : ligne « à assigner » de Tony Falcon affiche la date 30/07/2026 + ⓘ dont l'infobulle montre « Contact reçu le 30/07/2026 » + le résumé Marena ; 74 badges. node --check OK. Rapport source : workspace/prospects-contact-moa-2026-08-12.md. Capture workspace/moa-aassigner-info.png.
  • - 2026-08-12 (soir, +tard) — Échéance rapide « Aujourd'hui » → « Demain » + dates au format européen JJ/MM/AAAA. Demande Matt (idem MOA Properties). frontend/js/app.js : chip d'échéance du panneau « Prochaine étape » = « Demain » (quickDate('tomorrow'), +1 jour) au lieu d'« Aujourd'hui » ; branche tomorrow ajoutée à quickDate. Nouvelle clé i18n ld.dateTomorrow (es Mañana / fr Demain / en Tomorrow) dans i18n.js (ld.dateToday laissée en place, inutilisée). Dates numériques européennes forcées en 'fr-FR' (indépendant de la langue UI, pour éviter le MM/JJ américain) sur fmtDate (activités), fmtDayShort (badges pipeline / onglet Tâches / table) et fmtDay (label next-step) → {day:'2-digit',month:'2-digit',year:'numeric'} = 12/08/2026. Cache-bust ?v=20260812eudate. Vérifié live (Playwright, UI fr) : chip = Demain, dates de l'onglet Tâches = 12/08/2026. node --check app.js + i18n.js OK.
  • - 2026-08-12 (soir) — Fiche prospect en auto-save (plus de bouton « Enregistrer »). Demande Matt (même chose sur moa-properties-crm). Front only, fichiers statiques, aucun restart. frontend/js/app.js (openLeadDetail) : infra d'auto-save (buildPatch() = patch identique à l'ancien save() ; commitAll() PATCH /api/leads/:id + Object.assign(l,patch) + clearLeadDraft(l.id) + renderNextLabel() + indicateur ; scheduleSave(immediate) débounce 600 ms ; closeDetail() flush si pending puis closeModal()+renderView()). Câblage : les listeners input/change au niveau modalNode (qui alimentaient déjà le brouillon local) déclenchent aussi scheduleSave (input=débounce, change=immédiat) → couvre tous les champs f + le select assigné ; les tags (onclick) et les quick-dates d'échéance (valeur posée par programme) appellent scheduleSave(true) ; le stade (frise) était déjà auto-persisté. Le brouillon localStorage (filet anti-perte) est conservé et effacé après chaque commit serveur réussi. Boutons « Enregistrer » retirés : pied de fiche (remplacé par l'indicateur .autosave-status + bouton « Fermer » qui flush et rafraîchit) et panneau « Prochaine étape » (fonction morte saveNext supprimée, « Effacer » conservé). i18n (règle 3 langues) : nouvelles clés ld.autosaveHint/ld.saving/ld.saved/ld.saveErr (es/fr/en) dans i18n.js. CSS .autosave-status dans styles.css. Cache-bust ?v=20260812autosave. Périmètre : uniquement la fiche prospect ; les modales Création (openLeadEditor), Bien (openPropertyForm) et Ventes/Intake gardent leur bouton. Vérifié en live (Playwright) : pied = Supprimer + Fermer (aucun « Enregistrer »), édition d'un champ → « Enregistré ✓ » + persistance serveur (round-trip API) puis restaurée. node --check app.js + i18n.js OK.
  • - 2026-08-12 — Relève des leads Ladislas basculée sur IMAP direct (le flux Gmail était mort depuis mi-avril). Cause racine : contact@moa-realestate.com n'était plus relevée par moarealestate.sa@gmail.com (forwarding coupé ~14 avril 2026), donc AUCUN lead Ladislas « invest in Asuncion real estate » n'entrait dans le CRM depuis (Manfred Bortenschlager introuvable, etc.). Correctif : sources/ladislas_moa_import.py réécrit pour lire contact@moa-realestate.com directement en IMAP (serveur Jabatus mail.moa-realestate.com:993 SSL), au lieu de l'API Gmail. Identifiants dans /root/aios/data/.creds/moa_contact_imap.json (hors git, chmod 600). État seen basé sur les UID IMAP (réinitialisé, ancien état Gmail sauvegardé ladislas_moa_state.json.gmail-bak). Parsing enrichi : le numéro est reconstruit en E.164 via le champ « Phone country code » du formulaire (nom de pays → indicatif, table COUNTRY_DIAL ; gère aussi le préfixe 00 international sans double indicatif), et la note d'ingestion porte désormais le pays + le Message libre du prospect. sources/ladislas_moa_monitor.py : message d'alerte mis à jour (référence IMAP au lieu du compte Gmail). Backfill exécuté (--since 200) : 22 nouveaux leads créés « à assigner » (dont Manfred Bortenschlager +34647321309, Dr. Manfred Tanner, Monique den hollander, Adrian, etc.), 41 dédupliqués (déjà présents, 0 doublon créé grâce à samePhone). Cron */15 inchangé (--since 14), désormais alimenté par IMAP ; run de contrôle idempotent (0 nouveau). Constat sur les flux voisins : resident.paraguay@gmail.com reçoit toujours les leads Ladislas d'un formulaire DIFFÉRENT (« Brochure and Pricing for Paraguay » = résidence/RP) → rp-crm/sources/ladislas_import.py et le cockpit ne sont pas affectés. moa-automations/moa_ladislas_import.py (vers Odoo, legacy) lit le même Gmail mort (0 message) : laissé tel quel (Odoo en cours de remplacement par ce CRM). Mémoire : moa-ladislas-leads-automation mise à jour.
  • - 2026-08-12 — Détection de doublons (règle Matt : zéro doublon, ne jamais proposer un lead déjà assigné). Deux leads sont doublons s'ils partagent email OU téléphone (samePhone, tolérant à l'indicatif pays) OU nom COMPLET (>=2 mots ; un prénom seul est trop courant, ignoré). email/tel = signal fort, nom = faible. backend/server.js : buildDupIndex() (un passage, indexe email/tel/nom, renvoie par lead {count, strong, assignedOther, others:[{id,name,stage,assignee_name,via}]}), dupForLead(). Attaché à GET /api/leads (champ dup), GET /api/leads/:id (dup), et à unassignedList du /api/dashboard (+ dupLeads/dupClusters). Nouveaux endpoints admin : GET /api/duplicates (clusters = composantes connexes, triés « assignés à >=2 personnes » d'abord) et POST /api/duplicates/dismiss {ids} (table dup_dismissed, marque « pas un doublon »). Ingestion durcie (POST /api/leads/ingest) : dédup aussi par samePhone (un numéro Ladislas format national vs un existant +indicatif ne recrée plus de doublon : c'était la cause des 7 doublons Ladislas). frontend : badge ⚠ (rouge si un doublon est déjà assigné, orange si homonyme faible) à côté du nom dans le pipeline (leadCard), la table Prospects, la liste « à assigner » du dashboard, et la fiche (header + bannière détaillée listant les doublons cliquables avec leur agent/étape + consigne « préviens le responsable avant d'assigner »). Panneau admin « Doublons à résoudre » dans le tableau de bord (clusters, membres, bouton « Pas un doublon »). api.js : duplicates(), dismissDuplicate(ids). CSS .dup-badge/.dup-banner/.dup-cluster ajoutés. Cache-bust ?v=20260812dup. Constat à l'activation : 31 clusters de doublons, dont 14 où la même personne est assignée à 2+ agents (ex. Richard Bruijn → Seb+Vsevolod). Vérifié end-to-end (API + UI Playwright : badges liste/pipeline/fiche, bannière fiche, panneau dashboard), 0 erreur console, service redémarré. À FAIRE : répliquer sur moa-properties-crm et rp-crm (même patron). Règle mémoire: crm-no-duplicate-leads.
  • - 2026-08-11 — Formulaire de vente : retrait de 2 champs à la demande de Matt (doublon "Commentaires" + "Statut de l'agent"). (1) "Commentaires" (comentarios) supprimé de la section 6 (Contexte) : faisait doublon avec "Promesses faites au client" (promesas) juste au-dessus. (2) "Statut de l'agent" (agente_estatus, select Agent MOA / Affilié externe / Direct) supprimé de la section 5 (Agent et commission), ainsi que son sous-champ conditionnel "Préciser l'affilié" (agente_estatus_detalle) : ce n'est pas à l'agent de déclarer son statut, c'est vérifié côté admin par Matt. Le reste de la section 5 est conservé (agent connecté en lecture seule, commission attendue, partage de commission). Frontend frontend/js/app.js (openVentaForm) : champs retirés du rendu, de collectMissing (plus exigés), et de restoreDraft (plus de showCond orphelin) ; vue détail (openVentaDetail) : agente_estatus et comentarios rendus conditionnels (v.x ? kv(...) : null) → les anciennes ventes gardent l'affichage de leurs données, les nouvelles n'affichent pas de ligne vide. Backend backend/server.js (validateVenta) : agente_estatus et comentarios retirés des champs requis (agente_estatus n'est validé que s'il est fourni). Colonnes DB conservées (ventas.agente_estatus/agente_estatus_detalle/comentarios restent dans VENTA_FORM_FIELDS, insérées à null) → aucune migration, les ventes existantes intactes. Cache-bust app.js/i18n.js ?v=20260811d→?v=20260811e, SW moa-crm-v23-20260811d→v24-20260811e. node --check OK (app.js + server.js), service redémarré (health 200). Vérifié : le JS servi en ligne ne contient plus select('agente_estatus' ni textarea('comentarios'.
  • - 2026-08-11 — Fix libellé "++ Prospect" (bouton pipeline). Le bouton "+ Nouveau prospect" de la pipe-toolbar (frontend/js/app.js, renderPipeline) faisait '+ ' + t('btn.newLead') alors que btn.newLead contient DÉJÀ le + ("+ Prospect" / "+ Prospecto" / "+ Lead") → affichait "++ Prospect". Retiré le '+ ' en trop → [t('btn.newLead')]. Seul ce bouton était concerné (le bouton coordinateur ligne ~2407 utilise coord.newLead = "Nouveau lead" sans +, donc "+ Nouveau lead" correct ; le topbar new-btn utilise déjà t('btn.newLead') seul). Cache-bust ?v=20260810c→?v=20260811, SW v19→v20-20260811. Frontend only, pas de restart. node --check OK.
  • - 2026-08-11 — Formulaire de vente : sélecteur de projet basé sur une liste de PROJETS APPROUVÉS (découplée du catalogue scrapé) + Marena. Demande Matt : pouvoir vendre un promoteur signé même sans unités en live (ex. Marena, signé mais non scrapé), SANS saisie manuelle (liste fermée de projets approuvés uniquement). Nouvelle table approved_projects (db.js : id, prom_nom, proj_nom, catalog_proj_id nullable, active, created_at ; index unique (prom_nom, proj_nom)). Seed backend/scripts/seed_approved_projects.mjs (idempotent, relançable) : insère TOUS les projets du catalogue scrapé (moa-catalog/catalog.db, 147 projets, même sans dispo) + les signés manuels listés en dur dans MANUAL (Marena → Marena Torre 2 / Marena Torre 3, vérifiés dans Airtable « Unités vendues »). Résultat : 149 projets / 20 promoteurs. Backend : GET /api/approved-projects (auth, groupé par promoteur {promoters:[{nom,n_projets,projects:[{id,nom}]}]}) + gestion admin POST /api/approved-projects {prom_nom,proj_nom} (INSERT OR IGNORE + réactive) et DELETE /api/approved-projects/:id (active=0). Frontend buildProjectPicker (frontend/js/app.js) : openPicker lit API.approvedProjects() au lieu de catalogPromoters ; projectStep utilise promoter.projects (déjà en mémoire, plus de fetch catalogue). api.js : approvedProjects(). Rien hors liste n'est sélectionnable (pas de saisie libre). Vérifié Playwright (token admin) : recherche « Marena » → Marena (2 projets) → Torre 2/Torre 3 → sélection remplit promotor=Marena, proyecto=Marena Torre 2 ; Civis = 14 projets. node --check OK, restart moa-crm fait, endpoint 200. Pour ajouter un futur promoteur signé non scrapé : soit l'ajouter à MANUAL dans le script + relancer le seed, soit POST /api/approved-projects. NB : pas encore d'UI admin de gestion de cette liste (à faire si besoin). Pour info : le formulaire de vente n'écrit QUE dans la table locale ventas + envoie un email à moarealestate.sa@gmail.com (aucune synchro Airtable/Odoo). Table ventas = 0 ligne à ce jour (formulaire pas encore utilisé ; les vraies ventes vivent dans Airtable « Unités vendues »).
  • - 2026-08-11 — Pipeline : un admin s'ouvre par défaut sur SON propre book (sa vue d'agent), avec bascule « Tout ». Demande Matt : Sébastien (compte admin) voyait 0/100% dans son pipeline (les 553 leads) alors qu'il a 44 leads assignés et fait aussi du closing. Le mécanisme existait déjà (sélecteur d'agent du pipeline → assignee= respecté côté serveur pour les admins, GET /api/leads), il manquait juste le défaut. frontend/js/app.js : dans renderPipeline, si isAdmin() et pas encore initialisé (state.pipelineAgentInit), on force state.pipelineAgent = state.realMe.id UNE fois → le pipeline s'ouvre sur le book perso de l'admin ; le bouton existant « × Tous les agents » (affiché dès qu'un agent est sélectionné) sert de bascule « Tout » (retour aux 553). Si l'admin choisit « Tout » (null), son choix est respecté pour la session. Reset propre au login (afterAuth remet pipelineAgent=null + pipelineAgentInit=false). Les fonctions admin restent intactes (dashboard, agents, sélecteur d'agent, impersonation x-view-as). Agents/coordinateur inchangés (déjà hard-scopés serveur). Vérifié (token admin, backend) : sans filtre = 553, assignee=Sébastien = 44, assignee=Matt = 16. node --check OK, restart moa-crm fait, health 200. Frontend servi depuis le disque (recharger la page ; pas de cache-bust nécessaire, mais SW network-first).
  • - 2026-08-10 : Nouveau logo/icône de l'app (identité MOA, style app "dock"). Remplace l'ancienne icône skyline+soleil doré (le doré est abandonné par MOA). Icône façon WhatsApp : carré arrondi (squircle iOS) sur le vert MOA exact #0F5C4A (échantillonné sur le logo officiel MOA-refonte/site/assets/moa-logo-green.png), léger dégradé + glossy discret, avec le M de MOA (serif Trajan) et son ornement à deux anneaux au-dessus, recoloré en crème #FBF8F1. Le M est un crop du logo officiel (garantit la typo exacte), stocké en source dans /root/workspace/brand/_moa-crm-work/ (M-ornament.png, gen_icon.py/gen_all.py). Icônes régénérées dans frontend/icons/ : icon-192/512.png + favicon-32.png (squircle, coins transparents), icon-maskable-512.png + apple-touch-icon.png (pleine page, sans transparence pour le masque OS). Les badges in-app logo-rounded.svg (login) et logo-mark.svg (en-tête) embarquent désormais le PNG de l'icône (même identité, aucun changement HTML requis). Anciennes versions sauvegardées dans frontend/icons/_bak-2026-08-10/. Cache-bust HTML passé à ?v=20260810. Livrables preview pour Matt : /root/workspace/MOA-crm-logo/. Pas de restart nécessaire (assets statiques servis depuis le disque).
  • - 2026-08-10 — Bouton nouveau lead dans le Pipeline + onglet Biens défaut "Recherche unités" + i18n complète (audit + 2 vues traduites). (1) Pipeline : bouton + Nouveau prospect dans la pipe-toolbar (aligné à droite), ouvre openLeadEditor() (accessible aussi aux agents full-access, pas seulement au + Nouveau admin du topbar). Le lead arrive par défaut au stade sans_contact (new lead) mais le sélecteur de stade permet de le ranger ailleurs (f.stage.value='sans_contact' posé à l'ouverture). (2) Biens : l'onglet Recherche unités (bien.tab.search) est désormais le premier et l'onglet par défaut (catModes réordonné + cat.mode défaut passé de catalogue à search). (3) i18n : audit complet (script à accolades équilibrées) -> 547 clés, 0 trou sur es/fr/en, 0 clé t() sans entrée. Deux vues portaient encore du français en dur : WhatsApp (renderWhatsapp/paintWa/renderInbox/openThread/waAiAssist, ~29 chaînes -> clés wa.*) et Promoteurs (admin, templates innerHTML, ~29 chaînes -> clés promo.*). Toutes câblées sur t() et traduites ES/FR/EN. Vérifié Playwright : Pipeline "Nouveau prospect" défaut sans_contact ; Biens 1er onglet actif = Recherche unités ; bascule ES -> Promoteurs ("Firmados/Por firmar/Promotor/Comisión/✓ Al día…") et WhatsApp ("Recepción…/No conectado/Conectar mi WhatsApp") rendus en espagnol. Limite connue : les messages de diagnostic scrap de la vue Promoteurs (scrap.issues, ex. "liste de prix non scrapée") sont générés côté backend (server.js) en français ; les traduire demanderait une i18n backend (non fait, admin-only). Cache-bust ?v=20260810c, SW v18->v19-20260810c. Frontend only, pas de restart backend. node --check OK.
  • - 2026-08-10 — Suivi des prochaines actions : raccourcis de date, notes en avant, badges colorés par échéance, nouvel onglet Tâches. (1) Raccourcis de date dans le panneau next-step de la fiche (app.js) : chips "Aujourd'hui / Semaine proch. / Mois proch." (quickDate() + isoLocal()) qui remplissent next_action_date sans ouvrir le calendrier. Styles .ns-quick/.ns-chip, i18n ld.dateQuick/dateToday/dateWeek/dateMonth. (2) Notes remontées tout en haut de la fiche : le champ notes sort de la colonne left, rendu dans une bande pleine largeur .notes-band juste sous le nom (avant la timeline pipeline), textarea agrandi (min-height 150px, fond crème, liseré doré) via .notes-top. (3) Badge Pipeline coloré par échéance : nextStepBadge utilise nextActionStatus(date) -> classes st-future (vert), st-today (orange), st-overdue (rouge), st-none (doré, sans date). Remplace l'ancien doré+rouge. (4) Nouvel onglet "Tâches" (renderTasks, nav data-view="tasks" entre Prospects et Biens) : liste toutes les prochaines actions bookées sur les prospects (hors gagné/perdu), triées par échéance, groupées En retard / Aujourd'hui / À venir / Sans date, avec bandeau récap coloré et lignes cliquables (ouvrent la fiche). Filtre agent pour l'admin ; scopé au book perso pour un agent full-access. Réutilise GET /api/leads (déjà scopé par rôle). Gating : mêmes règles que Pipeline/Prospects (masqué aux agents non full-access). i18n nav.tasks, tasks.*. Cache-bust ?v=20260810b, SW v17->v18-20260810b. Frontend only, pas de restart backend. node --check OK. Vérifié Playwright (token admin) : onglet Tâches (0 en retard / 4 aujourd'hui / 2 à venir sur données réelles), notes en tête + agrandies, chips de date ("Semaine proch." -> +7j OK), badges Pipeline orange (aujourd'hui) et vert (futur). NB : le nombre de leads avec next_action a augmenté depuis le matin (les agents commencent à en saisir).
  • - 2026-08-10 — Indice visuel "prochaine action définie" sur les cartes Pipeline + table Prospects. Objectif : que l'agent voie d'un coup d'œil sur quels leads il a déjà posé un next_action (proximo paso). (1) Carte Pipeline (frontend/js/app.js) : nouvelle fonction nextStepBadge(l) rendue dans .lc-top (entre le nom et l'avatar). Badge doré ⏭ (charte --gold/--gold-soft) affichant la date d'échéance si présente, rouge (.overdue) si l'échéance est passée, tooltip = libellé complet du next_action + date. Rien affiché si next_action vide. Helper fmtDayShort(d) (format date court, niveau module, utilise localeTag). (2) Table Prospects : colonne "Prochaine action" enrichie d'une pastille dorée (.na-set/.na-dot) quand définie, sinon — grisé. (3) Styles (frontend/styles.css) : .lead-card .lc-next (+ .overdue), .na-set/.na-dot. (4) i18n (frontend/js/i18n.js) : clé card.nextSet (ES/FR/EN). Cache-bust styles.css/app.js/i18n.js ?v=20260810a, SW v16->v17-20260810. Frontend only, pas de restart backend. node --check OK. Vérifié Playwright (token admin) : badge ⏭ 12 août visible sur la carte "Angel" (Monica), pastille dorée dans la table Prospects sur les 2 seuls leads ayant un next_action (Angel, Nicolas Dominguez). NB : à ce jour seuls 2 leads sur 551 ont un next_action renseigné, donc le badge reste rare tant que les agents ne programment pas leurs prochaines actions.
  • - 2026-08-10 — Migration Odoo -> CRM de TOUS les agents restants + activation des comptes + accès complet ouvert à tous. (1) Import : backend/scripts/import_odoo_leads.py RÉÉCRIT en version générique (mapping Odoo user -> compte CRM par nom normalisé + alias facudo roman->facundo roman, au lieu du dict PEOPLE par user_id ; l'ancienne version reste dans l'historique git). Périmètre = crm.lead Odoo assignés à un vrai agent (user_id != pool "MOA Real Estate" id 2). Idempotent (dédup par odoo_lead_id), n'AJOUTE que. Résultat : 302 leads ajoutés (CRM 249 -> 551) : Facundo 103, Raphaël 99, Vsevolod 65, Lucas 12, Antonella 9, Abel 8, Thiago 6. 0 lead pour les déjà-migrés (Monica/Mathis/Alexis/Seb) => confirmé qu'ils n'ont pas de nouveau lead Odoo hors CRM. Origines : Immo/autre 258, Meta RU 28 (leads russes de Vsevolod, à surveiller car funnel distinct), Residence RP 15, MOA Direct 1. Backup avant : backend/data/moa-crm.db.bak-20260810-105534. Mapping stage Odoo->CRM dans le script (STAGE_MAP), Not Qualified/Lost -> perdu (lost_reason noté). (2) Comptes activés : node scripts/gen_invites.mjs a (ré)généré les liens des 9 comptes en attente (Abel, Antonella, Facundo, Lucas, Manuel, Mathis, Raphaël, Thiago, Vsevolod) -> récap /root/workspace/moa-crm-invitations.md. Actifs déjà onboardés (Alexis, Monica, Sébastien, Gary, Matt) non régénérés. (3) Accès complet (Pipeline+Prospects) : FULL_AGENT_EMAILS dans backend/.env étendu de {Monica, Alexis} à TOUS les agents + Gary (sinon un agent ne voit que "Biens"). Restart moa-crm fait. NB import one-shot (pas de cron) : les FUTURS leads Odoo des agents ne remonteront pas seuls, relancer le script au besoin (idéalement le mettre en cron).
  • - 2026-08-07 — Monica Ayala : accès agent COMPLET (Pipeline + Prospects débloqués) + import de ses 78 leads Odoo. (1) Capability full_access : un agent standard ne voit que Biens + Ventas (Pipeline/Prospects = écran « à venir »). Nouvelle liste blanche d'emails, calquée sur le pattern WA_ALLOWED : backend/server.js → FULL_AGENT_ALLOWED (défaut monicaayala@moa-realestate.com, extensible via FULL_AGENT_EMAILS dans backend/.env), helper fullAgent(u), exposé dans publicUser comme full_access. Côté front frontend/js/app.js : helper fullAgent() + defaultView(), le gate « à venir » (renderView), la visibilité de la recherche globale et la vue d'atterrissage utilisent désormais full_access au lieu de isAdmin() seul (un full-access agent atterrit sur Pipeline). Sécurité inchangée : le serveur hard-scope déjà tout agent à son propre book (/api/leads force assignee_id = self), donc Monica ne voit QUE ses leads. Les autres agents restent sur « à venir » tant que leur email n'est pas ajouté. WhatsApp était déjà activé pour Monica (même liste WA_ALLOWED). (2) Import Odoo : backend/scripts/import_odoo_leads.py — ajout de Monica au mapping PEOPLE (Odoo user_id 12 → CRM user KQWg7q8NywGjHS7h). Import idempotent (skip sur odoo_lead_id déjà présent) : 78 leads insérés (perdu 51, sans_contact 11, interesse 7, contacte 6, gagne 2, rdv_pris 1), intake_source='odoo_import', assignee_id=Monica. Backup DB avant : backend/data/moa-crm.db.bak-*-preMonica. Restart backend fait (server.js). Cache-bust app.js?v=20260807e, SW v15→v16-20260807. node --check OK (app.js + server.js). Vérifié : /api/me de Monica → full_access:true, wa_enabled:true, /api/leads = 78 (scopé) ; Playwright avec un jeton Monica → atterrit sur Pipeline (board de SES leads : sans_contact 11, contacté 6, interesé 7, cita 1, venta 2 ; les 51 perdus exclus du board), Prospectos = 78 lignes, 0 écran « à venir », nav sans Dashboard/Agents. NB import one-shot : le script n'insère que les NOUVEAUX leads Odoo, il ne met pas à jour le stade des leads déjà importés. Pour faire remonter automatiquement les futurs leads Odoo de Monica, prévoir un cron sur ce script (pas encore en place ; idem Mathis/Seb, imports one-shot).
  • - 2026-08-06 — Fiche lead, réorg colonne droite : saisie d'activité retirée, "Proposé au client" remonté en haut, logs conservés en bas. La barre de saisie rapide d'activité (boutons Appel/WhatsApp/Email/Visite/Note + champ note libre) est retirée de la fiche (jugée inutile). L'historique/logs (timeline des activités auto : changements de stade, relances, messages) est conservé sous le titre "Activité", déplacé en bas de la colonne droite. La section "Proposé au client" (promoteurs + unités du catalogue) est remontée tout en haut de la colonne droite. Nouvel ordre colonne droite : Proposé au client → Relances (si contacté) → Perdu (si perdu) → Biens → Activité (logs). Frontend only : frontend/js/app.js (réordonnancement du tableau right ; qa/actInput/logActivity ne sont plus rendus, définitions laissées inertes). Cache-bust app.js ?v=20260806i. SW v7→v8-20260806. Pas de restart backend. node --check OK ; vérifié Playwright (0 bouton d'action, 0 champ note, 8 lignes de logs conservées sous "Activité", section Proposé au client en tête).
  • - 2026-08-06 — Fiche lead, retouches UI : bouton "Prochaine étape" déplacé sous la timeline en gros CTA + en-tête de colonnes du pipeline = montant seul. (1) Le bouton Prochaine étape n'est plus en haut de la colonne droite : il est désormais sous la timeline de pipeline, en pleine largeur (nouvelle bande .next-step-band entre stage-band et modal-body ; retiré du tableau right). Style refait en CTA fort dans la charte : fond dégradé vert forêt (--forest-3→--forest), texte crème, icône dorée (--gold-soft), ombre portée, et liseré doré à gauche quand une action est définie (.set). Impossible à manquer. (2) En-tête de colonne du pipeline : la ligne pipe.commission affiche uniquement le montant (ex. 18,8 k $US) au lieu de … de commission est. (i18n {v} seul, ES/FR/EN). Frontend only : frontend/js/app.js (placement du nextStepBox), frontend/js/i18n.js (pipe.commission), frontend/styles.css (.next-step-band/.next-step-btn refaits, .stage-band sans bordure basse). Cache-bust styles.css/app.js ?v=20260806h, i18n.js ?v=20260806g. SW v5→v7-20260806. Pas de restart backend. node --check OK ; vérifié Playwright (CTA vert plein largeur sous la timeline avec liseré doré sur un lead ayant une action).
  • - 2026-08-06 — Fiche lead : associer promoteur(s) et unité(s) du CATALOGUE au client, avec notes ("Proposé au client"). Nouvelle section dans la fiche : (1) Promoteurs proposés : menu déroulant listant les promoteurs du catalogue (API.catalogPromoters), chaque promoteur ajouté a son propre champ Note (mémo agent, sauvegarde auto au blur / après 600 ms) ; croix pour retirer. (2) Unités proposées : champ de recherche d'unité (code, projet…) qui interroge API.catalogUnitsSearch (≥2 caractères, 8 résultats max), un clic ajoute l'unité ; chaque unité liée affiche code · prix + sous-titre projet · promoteur + champ Note + croix. Le catalogue (moa-catalog/catalog.db) restant une DB séparée en lecture seule, les liens sont stockés côté CRM (moa-crm.db) avec un snapshot dénormalisé (nom promoteur ; code/projet/promoteur/prix/moneda de l'unité) pour un affichage instantané, sans jointure cross-DB et robuste si le catalogue évolue. Implémentation : backend/db.js (2 tables additives lead_catalog_promoters et lead_catalog_units + index par lead ; ⚠️ piège corrigé : un backtick dans un commentaire SQL fermait le template literal JS de db.exec → ne jamais mettre de backtick dans ces commentaires), backend/server.js (shapeLead renvoie catalog_promoters/catalog_units ; 6 endpoints POST/PATCH(note)/DELETE sous ensureLeadAccess), frontend/js/api.js (addLeadPromoter/updateLeadPromoter/removeLeadPromoter + idem units), frontend/js/app.js (bloc renderCatalog() dans openLeadDetail, section insérée avant "Biens"), frontend/js/i18n.js (clés ld.proposed*/ld.addPromoter/ld.searchUnit/ld.notesPh… ES/FR/EN), frontend/styles.css (.cat-item/.cat-note/.cat-add/.cat-results/.cat-result). NB : la section interne "Biens (liés/suggérés)" (table properties, données de démo) est conservée en dessous, distincte du catalogue réel ; à supprimer si Matt la juge redondante. Cache-bust styles.css/i18n.js/app.js ?v=20260806f. SW v4→v5-20260806. Restart backend requis (db.js + server.js) : effectué. node --check OK ; vérifié Playwright (ajout promoteur Zuba via dropdown, recherche "Zuba" = 8 unités, ajout unité 1504 avec snapshot prix/projet, note promoteur sauvegardée et persistée en DB, 0 erreur console) ; données de test supprimées ensuite.
  • - 2026-08-06 — Fiche lead refondue : drag-drop instantané, timeline de pipeline cliquable, gros bouton "Prochaine étape", champ Motivation retiré. (1) Drag-drop sans délai ni refresh : au drop d'une carte dans une autre colonne, renderPipeline ne re-fetch/ne re-render plus tout le board ; la carte est déplacée en DOM de façon optimiste et les compteurs + montants de commission des deux colonnes recalculés en local (helper updateColStats, à partir des data-id/data-rev posés sur .lead-card), l'appel API.updateLead part en arrière-plan avec rollback (carte remise à sa place + toast) si l'API échoue. L'âge de la carte passe à "aujourd'hui". (2) Stade en timeline éditable directement depuis la fiche : l'ancien <select> de stade (dans l'en-tête) est remplacé par une timeline horizontale des 9 étapes du pipeline (.stage-band/.stage-tl/.stage-node, faites ✓ vertes / courante dorée / à venir), chaque étape cliquable = change le stade immédiatement (setStage → API.updateLead, rollback sur erreur) et re-render relances + champ perdu. Bouton "Marquer perdu" séparé à droite (toggle Perdu ↔ Contacté) ; le select Motif de perte n'apparaît que quand le lead est en Perdu (syncStageDependent). (3) "Prochaine étape" = gros bouton visible en haut de la colonne droite (.next-step-btn) : affiche l'action en cours ou "+ Définir la prochaine étape" ; au clic, un panneau de saisie rapide (action + échéance + Enregistrer/Effacer) persiste direct via API.updateLead. Les inputs restent f.next_action/f.next_action_date (relances synchronisées via renderNextLabel). (4) Champ "Motivation" supprimé de la fiche (l'inp('motivation') retiré ; colonne DB conservée, simplement plus éditée). Implémentation frontend only : frontend/js/app.js (drop optimiste + leadCard datasets ; openLeadDetail : timeline, setStage, next-step, lost dynamique, save() lit l.stage), frontend/js/i18n.js (clés ld.nextStep/nextEmpty/nextClear/markLost/reopen ES/FR/EN), frontend/styles.css (.stage-band/.stage-node/.next-step-btn/.ns-panel). Cache-bust styles.css?v=20260806e, i18n.js?v=20260806d, app.js?v=20260806d. SW moa-crm-v3-20260806 → v4-20260806. Pas de restart backend (statique). node --check OK ; vérifié Playwright (login admin jeton de test) : timeline rendue, clic étape 3 → étapes 1-2 vertes + 3 dorée persisté, gros bouton "Relancer WhatsApp" ouvrant le panneau, "Marquer perdu" sans chevauchement, plus de champ Motivation ; lead de test (Eddy Belleville) restauré à son stade d'origine.
  • - 2026-08-06 — Aide "?" en tête de chaque colonne du pipeline (explication de l'étape au survol). Chaque colonne du kanban (vue Pipeline) porte une pastille « ? » dans son entête ; au survol (ou focus clavier, ou tap mobile) elle affiche un tooltip expliquant à quoi sert l'étape et quoi faire, pour que chaque agent comprenne le pipeline sans formation. Textes fidèles au modèle Henrique Portella (base experts workspace/CRM-bonnes-pratiques-experts.md, réunion conseil 2026-05-15) : ex. Sans contact = lead entré à contacter vite ; Contacté = contact tenté sans réponse, c'est là que s'applique la séquence des 5 relances MOA Academy ; Perdu = toujours mettre un motif, ne jamais supprimer. Implémentation frontend only : frontend/js/i18n.js (10 clés stagehelp.<key> + stagehelp.aria, en ES/FR/EN, ES = langue par défaut des agents), frontend/js/app.js (dans renderPipeline, span .col-help ajouté au col-head, réutilise le tooltip custom showTip/hideTip déjà présent ; handlers mouseenter/mouseleave + focus/blur + click + Enter/Espace/Escape ; le clic affiche simplement le tooltip, pas de toggle, car focus+click se neutralisaient et cassaient le tap mobile), frontend/styles.css (.col-head .col-help, pastille ronde 16px, hover/focus vert forêt ; le .tip-pop gère déjà max-width 280 + retour à la ligne). Cache-bust styles.css?v=20260806b, i18n.js?v=20260806b, app.js?v=20260806c. SW moa-crm-v2-20260806 → v3-20260806. Pas de restart backend (statique). node --check OK (app.js + i18n.js) ; vérifié Playwright sur crm-moa.panelbay.com (login admin via jeton de test, colonnes portent le « ? », tooltip FR affiché au survol sur « Contacté »). NB : la vue Pipeline est réservée aux admins (les agents ont « à venir » sur Pipeline/Prospects), mais les libellés d'aide sont prêts pour quand la vue leur sera ouverte.
  • - 2026-08-06 — Suivi des 5 relances (script MOA Academy) sur la fiche, en 1 clic. Quand un lead est au stage Contacté (sans réponse), sa fiche affiche en haut de la colonne droite un bloc « Relances (script 5 jours) · X/5 » : 5 pastilles (faites ✓ / en cours / à venir), l'intitulé + le détail de l'étape du jour, et un bouton « Relance Jn lancée » qui, en 1 clic, incrémente l'étape, historise l'action (activité type relance), pose la prochaine action + échéance à J+1 (alimente l'alerte « en retard » du dashboard) et affiche l'étape suivante. Lien Annuler (recule d'un cran, en cas de faux clic) + date de dernière relance. À 5/5 : message « séquence terminée, statuer (Perdu/Intéressé) ». Le script est le vrai MOA Academy (J1 offre image+texte, J2 audio, J3 appel après 18h30, J4 info valorisation, J5 question directe), fourni en ES/FR/EN (l'agent voit sa langue ; le next_action stocké est en ES, langue de l'équipe). Implémentation : backend/db.js (constante RELANCE_STEPS es/fr/en + migration colonnes leads.relance_step INTEGER DEFAULT 0 et relance_last_date TEXT), backend/server.js (RELANCE_STEPS exposé dans /api/meta ; nouvel endpoint POST /api/leads/:lid/relance avec {undo}, sous ensureLeadAccess, transaction + activité), frontend/js/api.js (relance()), frontend/js/app.js (bloc renderRelance() dans openLeadDetail, re-render au changement de stage, icône timeline relance 🔁), frontend/js/i18n.js (clés ld.relance* es/fr/en), frontend/styles.css (.relance-box/.relance-dots/.relance-dot/.relance-next). SW moa-crm-v1-20260806 → v2-20260806. node --check OK sur les 5 fichiers, moa-crm.service redémarré, testé end-to-end en HTTP (avance 1→2, undo, activités, next_action=ES, échéance J+1) puis vérifié au navigateur sur un lead réel (Eddy Belleville, restauré à 0/5 après test). Aligne le CRM sur la stratégie 5 relances (cf. workspace/MOA-Academy/00-base-connaissances-agents.md et CRM-bonnes-pratiques-experts.md). Note : le bloc s'affiche pour le stage contacte (les tentatives de connexion à froid) ; le stage relance du pipeline reste le follow-up à chaud une fois le client intéressé.
  • - 2026-08-06 — PWA : bouton "Installer l'app" (installation sur le bureau / l'écran d'accueil). Le CRM est désormais installable comme une app. Ajouts : frontend/manifest.webmanifest (nom MOA CRM, start_url/scope /, display:standalone, theme/background #0A2A21, icônes), frontend/sw.js (service worker, cache le shell, network-first partout, ignore totalement /api/* pour ne jamais servir de données périmées, skipWaiting+clients.claim), frontend/js/pwa.js (enregistre le SW ; bouton flottant bas-droite ; utilise l'invite native Chrome/Edge via beforeinstallprompt quand dispo, sinon ouvre une modale d'instructions selon l'OS iOS/Safari-Mac/Firefox ; libellés ES/FR/EN lus depuis localStorage.moa_crm_locale ; masqué si déjà en mode standalone), frontend/icons/* (192/512/maskable-512/apple-touch-180/favicon-32, générées via PIL, forest green + "MOA" serif crème + "CRM" gold). index.html : <link rel=manifest>, apple-touch-icon, métas mobile-web-app-capable/apple, <script js/pwa.js>, cache-bust CSS→?v=20260806a. CSS du bouton/modale ajouté à styles.css. CSP : aucun script inline (interdit ici), tout dans pwa.js servi en 'self'. Modif frontend statique → pas de restart backend requis. Vérifié Playwright sur https://crm-moa.panelbay.com/ : bouton visible, service worker active, manifest chargé (application/manifest+json), 0 erreur CSP (seul un 401 /api/me attendu hors login).
  • - 2026-08-03 — Onboarding 1re connexion réordonné : LANGUE d'abord (demandée en espagnol par défaut), PUIS mot de passe dans la langue choisie. afterAuth (app.js) force setLocale('es')+applyLocale('es') avant promptLanguage, applique la langue choisie, puis promptPasswordChange (donc affiché dans la bonne langue). Cache-bust app.js?v=20260803c. Vérifié Playwright (picker « Elige tu idioma » en 1er, puis « Changez votre mot de passe » en FR après choix Français).
  • - 2026-08-03 — Comptes agents, rôles/permissions, vue globale admin, onboarding 1re connexion, i18n complet ES/FR/EN. (1) Provisioning (backend/scripts/provision_agents.mjs, idempotent) : logins convertis en prenomnom@moa-realestate.com (accents retirés), mot de passe temporaire COMMUN MoaCRM2026!, must_change_password=1, locale=NULL. Admins = Matt (login/pw inchangés) + Sébastien Beaupied ; tous les autres = agent. Récap écrit dans /root/workspace/moa-crm-acces-agents.md. (2) Migrations DB (db.js, idempotentes) : colonnes must_change_password, locale sur users. (3) Auth backend (server.js) : publicUser expose locale+must_change_password ; POST /api/change-password (efface le flag), PATCH /api/me {locale}. Impersonation : header x-view-as honoré si le demandeur est admin (req.acting) ; GET /api/leads scope les prospects sur l'agent visé. (4) Rôles/permissions frontend : admin = tout + picker « Vue globale » (topbar) ; agent = Biens seulement, Dashboard+Agents masqués, Pipeline+Prospects → page « À venir prochainement » (renderComingSoon). (5) Onboarding 1re connexion : popup changement de mot de passe bloquant, puis choix de langue (3 drapeaux ES/FR/EN). (6) i18n complet : frontend/js/i18n.js (dictionnaire ES/FR/EN, source unique des libellés), t() partout dans app.js ; langue persistée (users.locale + localStorage moa_crm_locale), formats nombre/date via localeTag(). api.js : setViewAs, changePassword, setLocale. Restart moa-crm requis (db.js+server.js). Vérifié Playwright : admin FR + picker, impersonation Monica (nav réduit, bannière, coming-soon), agent Abel (popup mdp → drapeaux → CRM en espagnol, Biens only, persistance après reload). ⚠️ Vsevolod n'a qu'un prénom → login vsevolod@moa-realestate.com (compléter le nom si besoin).
  • - 2026-08-02 — Brochures : inclure les promoteurs sans données prix (Marena, Avanza, Nativus…). brochures() (dans catalog.js) itérait sur la table promoteurs de la DB, donc un promoteur présent uniquement sur disque (moa-catalog/docs/<promoteur>/) mais absent de la DB (pas de prix scrapables) ne montrait aucune brochure. Réécrit pour scanner directement les dossiers de docs/ (nom DB propre si le dossier matche un promoteur, sinon nom du dossier ; prom_id null si hors DB). Résultat : Marena (7 brochures) et Avanza (6) apparaissent maintenant dans l'onglet Brochures. Restart moa-crm requis (catalog.js). Vérifié via l'API (7 promoteurs, Marena=7).
  • - 2026-08-02 — Bulle ₲ : tooltip instantané + non-cliquable. L'attribut title natif (délai ~1s, et le clic déclenchait la navigation de ligne) est remplacé par un tooltip custom : helper showTip/hideTip dans app.js (div .tip-pop ancré au body, position:fixed donc jamais rogné par l'overflow, affichage immédiat sur mouseenter). La bulle a onclick avec stopPropagation (ne navigue plus) et cursor:default. CSS .tip-pop ajouté. Frontend statique, pas de restart. Testé Playwright (tooltip visible au survol, clic reste sur Recherche).
  • - 2026-08-02 — Taux de change USD/PYG dynamique (taux du jour). Fini le taux fixe (7300, faux : réel ~6000). Nouveau moa-catalog/scripts/update_rate.py récupère le taux du jour (open.er-api.com + secours fawazahmed0, garde-fou 4000-12000) et écrit moa-catalog/rate.json ; lancé chaque matin par run_sync_prices.sh (avant le sync prix). catalog.js : pygPerUsd() relit rate.json à la volée (cache 1h, repli 6000), utilisé dans unitsSearch pour usdExpr et le champ rate renvoyé. Le frontend (bandeau + tooltip bulle ₲) affiche déjà data.rate, donc se met à jour seul. Restart moa-crm requis (catalog.js). Vérifié : 6004.95 ₲/$, Veralta 1-2-7 = 65 277 $.
  • - 2026-08-02 — Tableau Recherche unités : tout en USD. renderResults affiche désormais Prix et Prix/m² toujours en USD (prix_usd), colonne "≈ USD" séparée supprimée. Pour une unité d'origine en guaraníes, une bulle ₲ (.cur-badge) est ajoutée à droite du prix ; son title (tooltip au survol) donne le prix d'origine réel en ₲ + le taux de conversion (ex "Prix d'origine : 391 986 000 ₲ (converti en USD au taux ~7 300 ₲/$)"). Modif frontend statique (app.js + styles.css), pas de restart. Testé Playwright (21 bulles Veralta, tooltip OK).
  • - 2026-08-02 — Restructuration de la section Biens en 3 sous-onglets (segmented control, aucune entrée nav ajoutée) : Catalogue (drilldown existant), Recherche unités, Brochures. (1) Recherche multi-critères cross-promoteurs (unitsSearch + route /api/catalog/units/search) : filtres prix min/max en $, taille (chambres via habOf), m² min, promoteur, texte libre ; prix normalisés USD (PYG_PER_USD) pour trier/filtrer le mélange ₲/$, clic sur une ligne = va au projet. (2) Onglet Brochures (brochures() + route) : toutes les brochures PDF/DOCX/PPTX, groupées par promoteur, recherche instantanée, téléchargement direct. (3) Docs par projet en menus déroulants : le dump "100% des docs" au niveau promoteur est retiré ; units() renvoie désormais docsByType (classés via classifyDoc : brochure/prix/mémoire/ficha/plans/matériel/administratif) + docsScope, rendus en <details> repliables (1er ouvert) dans le détail projet. Nouveau CSS : .seg, .filter-bar, .inp, .doc-drop(s). Restart moa-crm requis (catalog.js + server.js). Testé Playwright (4 vues OK).
  • - 2026-08-03 — Refonte section Biens : cartes promoteur "hero", masquage des vides, recherche avancée, filtres persistés par agent. (1) Catalogue : les projets à 0 unité sont masqués par défaut (renderCatProjects filtre n_unites>0), + case à cocher "Afficher les projets sans unités (N)". Les promoteurs sans unités sont aussi masqués par défaut (renderCatPromoters), + case "Afficher les promoteurs sans unités (N)". Toggle = cat.showEmpty (persisté). (2) Cartes promoteur refondues en cartes "hero" (.promo-hero, 190px) : nom du promoteur en gros (serif), photo de fond du projet le plus emblématique quand un vrai rendu/façade existe dans docs/ (heroImageFor dans catalog.js : score fachada/exterior/render/vista/aerea, exclut plans/isométries/logos, seuil score>0 sinon dégradé de marque forestier), sinon dégradé ; logo réel si présent (logoFor) sinon monogramme (initiales). Override manuel possible : moa-catalog/docs/_hero.json = {"NomPromoteur":"chemin/rel/image.jpg"} (gagne toujours). ⚠️ Réalité assets : les drives contiennent surtout des plans/isométries/marketing, PAS de rendus façade propres → la plupart des promoteurs sont en dégradé (honnête). (3) Recherche avancée (renderUnitSearch) : panneau <details> pour EXCLURE des villes (excludeCities) et des promoteurs (excludePromoters) via chips ; backend unitsSearch ajoute les clauses NOT IN ; nouvelle route GET /api/catalog/cities + catalog.cities(). (4) Filtres persistés PAR AGENT : saveCatPrefs/ensureCatPrefs stockent search+showEmpty+excludeCities+excludePromoters dans localStorage clé moa_crm_catprefs_<userId> ; rechargés à l'entrée de Biens. Restart moa-crm requis (catalog.js + server.js). Vérifié Playwright : cartes hero (Zuba avec photo, reste en dégradé + monogrammes), toggle projets/promoteurs vides, exclusion Zuba (1251→sans Zuba) persistée après reload, 14 chips villes/promoteurs.
  • - 2026-08-02 — Colonne Prix/m² ajoutée au tableau d'unités (section Biens). Calculée côté frontend (renderCatUnits dans frontend/js/app.js) : prix / m² arrondi, même devise que l'unité, "—" si prix ou m² absent. Aucun scraping ni changement backend (prix, m², moneda déjà renvoyés par catalog.js units()). Modif frontend statique → pas de restart requis.
  • - 2026-08-02 — Indicateur de fraîcheur dans Biens : catalog.js projects() ajoute maj = MAX(u.updated_at), le frontend affiche un tag "MAJ <date>" par projet (restart moa-crm requis pour charger catalog.js). Flux prix manuel hors-Drive via moa-catalog/scripts/ingest_pricelist.py (source_file='manual'). Veralta = source WhatsApp : son auto-sync Drive est désactivé (fichier figé 26 mai), ses unités stampées au 26/05 pour afficher la vraie date.
  • - 2026-08-02 (v2 Zuba, avec PRIX) — sync_zuba.py refondu. Découverte : les projets en prévente ont un layout tabulaire DPTO/DEPTO | TIPOLOGIA | M2 | Precio Lista | Precio Obra | Precio Contado (prix en USD) ; les projets livrés gardent le layout matrice-couleur sans prix. Extracteur unifié : parse_table (avec prix, en-tête dpto/depto/unidad) OU parse_matrix (couleur) en fallback, tous les onglets lus (fix : projets multi-tours ne lisaient qu'un onglet → gros sous-comptage), onglets "Copia"/cocheras ignorés, dédup codigo au niveau projet. Pièges résolus : (1) "Precio Lista" est une FORMULE (=ROUND(...)) → charger openpyxl avec data_only=True sinon on lit le texte de la formule ; (2) garde-fou de plausibilité prix (_price, [15k-3M] USD, écarte 0/totaux/artefacts). Résultat : 20 projets, ~3758 unités, 510 dispos, 1730 avec prix USD (35k-243k$). Le prix stocké = Precio Lista. Purge des anciennes lignes Zuba (DELETE WHERE source_file='zuba') avant chaque sync car les codigos changent (préfixe de tour).
  • - 2026-08-02 — Ingestion Zuba dans le catalogue (nouveau script dédié moa-catalog/scripts/sync_zuba.py, câblé au cron prix quotidien via run_sync_prices.sh). Format Zuba très différent : chaque projet a un Sheet "ocupacion" où le STATUT est codé par la couleur de remplissage (blanc=libre, gris=vendido, orange FF9900=señado). L'annuaire projet→sheet est lu dynamiquement depuis "LINKS DE OCUPACION.xlsx". Le frontend gère prix: null (affiche "—" via money()), aucun changement code moa-crm. Docs Zuba ajoutés à sync_docs.py.
  • - 2026-08-02 — Ingestion Civis dans le catalogue (via moa-catalog/scripts/sync_prices.py) : le Sheet unique "LISTAS DE PRECIOS Y STOCK" (14 onglets projet) est désormais éclaté en 12 projets (Arazá I/II, Æther, Civis X/XI, Soho, Mariscal, Jardinia, Villa Morra Flats, Alpha, Flats Las Mercedes, Flats del Sol) soit 941 unités, 67 dispos. Parseur civis refondu (par onglet, capte dispos ET vendues), map CIVIS_TABS, parse_price durci (nbsp / $ -). Civis apparaît maintenant comme dossier promoteur dans Biens (docs en cours de rapatriement par le cron docs). Rien de modifié côté moa-crm (lecture live).
  • - 2026-08-02 — Section Biens transformée en catalogue promoteurs (drilldown promoteur → projet → unités dispos + documents), lecture seule depuis moa-catalog/catalog.db. Nouveau backend/catalog.js + 4 routes /api/catalog/*. Frontend renderProperties réécrit (dossiers, fil d'Ariane, table d'unités avec toggle réservées/vendues, panneau documents groupés par catégorie avec téléchargement). CSS ajouté (folder-grid, doc-groups, units-table, crumbs). Bouton "+ Bien" masqué (catalogue read-only). Testé de bout en bout (endpoints + rendu Playwright + download PDF 200).
  • - 2026-08-02 — Manuel initialisé (bootstrap auto).
  • - 2026-08-03 — Catalogue tulugar: couverture complète (bug "0 disponible" corrigé). Le scraper moa-catalog/scripts/scrape_tulugar.py ne crawlait qu'un seul statut (--filter en-pozo, 27 projets), donc ~105 projets des autres statuts (en-construccion, completados) étaient invisibles, dont Palmanova Center (203 unités) signalé par Matt. Corrigé : nouveau scrape_listing_all() = liste globale /proyectos UNION chaque statut, dédup par slug (132 projets), défaut --filter all, garde-fou de couverture (abort si le listing paraît tronqué → ingest sauté). run_sync_prices.sh passe en --filter all. ingest_tulugar.py lit scrape_all.json et respecte la devise par unité (u.currency, ne force plus USD → ne fausse plus les unités en ₲). Résultat: 18 projets avec grille (vs 4), catalogue 6317 unités. Côté CRM: frontend/js/app.js affiche "dispo à consulter" au lieu de "0 dispo" quand un projet n'a pas de grille publiée par la source (les 23 vrais sans grille ne sont plus lus comme "vendus"); cache-bust app.js?v=20260803. Modif frontend statique → pas de restart backend requis (catalog.js lit la DB en live). Leçon: has_sheet_source n'est PAS le signal de dispo (Palmanova Center = false + 203 unités); ne jamais confondre "absent de mon sous-ensemble scrapé" avec "absent du site". Détail parseur dans le skill moa-catalog-drives.
  • CHANGELOG

  • - 2026-08-25 : Formulaire « + Nouveau lead » (Gary) : détection de doublon EN DIRECT. Vrai besoin de Gary (précisé) : quand il saisit un prospect à la main, savoir tout de suite s'il l'a déjà rentré. frontend/js/app.js openIntakeModal : encart dupBox sous les champs ; à chaque saisie de nom / apellido / whatsapp / email (debounce 350 ms), appel API.coordLeadSearch(bestDupQuery()) (priorité : whatsapp ≥5 chiffres > email > nom+prénom ≥3 car.), garde anti-course (dupSeq). Résultat : encart orange « ⚠ N prospect(s) déjà dans le CRM » listant nom · tél · statut (à assigner / Assigné à <agent> · <étape>), ou vert « ✓ Aucun doublon trouvé » si rien. Rappelé aussi après extraction WhatsApp (checkDup() dans doExtract). Réutilise l'endpoint /api/leads/search de la veille. i18n intake.dupFound/dupNone, CSS .intake-dup. Cache-bust ?v=20260825intakedup, SW moa-crm-v50-20260825intakedup. Testé Playwright (Gary) : « Kevin Loidts » → orange « Assigné à Sébastien Beaupied · Vente conclue » ; nom bidon → vert « tu peux le créer ». (La barre de recherche de l'onglet Leads à assigner, ajoutée juste avant, reste en place comme complément.) Pas de restart (statique).
  • - 2026-08-25 : Coordination (Gary) : barre de recherche de leads dans tout le CRM (anti-doublon) + fix file à assigner cassée. Demande de Gary : pouvoir chercher un prospect par nom/prénom pour savoir s'il existe déjà (dans le pool à assigner OU déjà assigné à un agent), avant d'en créer un. Backend : nouvel endpoint GET /api/leads/search?q= (auth, requireCoordinatorOrAdmin) qui cherche dans TOUS les leads (nom / email / téléphone / whatsapp, + variante digits-only pour les numéros) et renvoie {rows:[{id,name,phone,email,source,stage,created_at,assignee}]} (assignee = nom de l'agent si assigné, sinon null = à assigner), trié pool-d'abord, limite 60. BUG PRÉ-EXISTANT corrigé : la route /api/leads/:lid (l.767) captait /api/leads/queue ET /api/leads/search (« search »/« queue » interprétés comme un id → « Lead introuvable »), donc l'onglet « Leads à assigner » de la Coordination ne chargeait JAMAIS la file. Fix : :lid fait next() sur les mots réservés ['queue','search']. Frontend (coordLeads) : ajout d'une barre de recherche ; vide = file des leads à assigner (comme avant, désormais fonctionnelle) ; ≥2 caractères = recherche globale affichant nom / tél / source / statut (badge « à assigner » ou « Assigné à <agent> · <étape> ») / date, + compteur. api.js coordLeadSearch(q). i18n ES/FR/EN (coord.leadSearchPh/None/Count, coord.th.status, coord.assignedTo). Cache-bust ?v=20260825coordsearch, SW moa-crm-v49-20260825coordsearch. Testé : endpoint (Gary) « loidts » → Kevin Loidts assigné à Sébastien (Vente conclue) ; queue = 500 leads ; Playwright (Gary) Coordination → onglet Leads à assigner → recherche « loidts » affiche la ligne avec statut. Restart moa-crm fait.
  • - 2026-08-25 : Onglet « Objectifs » — suivi hebdo des objectifs agents (leads parlés / RDV / ventes) avec réalisé auto anti-erreur. Routine Matt (chaque lundi l'agent pose ses objectifs, revue le vendredi). Backend : nouveau module backend/agent_goals.js (table agent_goals(agent_id, week_start, goal_leads/rdv/ventes, updated_at/by) créée à l'import) + endpoints GET/POST /api/goals?week=YYYY-MM-DD (gatés requireCoordinatorOrAdmin = admin + Gary). Réalisé calculé auto depuis le pipeline (activités type='stage', body « Ancien → Nouveau ») : RDV = leads passés à rdv_pris, ventes = gagne, leads parlés = premier contact réel d'un lead sourcé par l'agent (intake_source ∈ {manual, whatsapp_inbox} ; les leads distribués comptent pour RDV/vente mais PAS comme nouveau lead parlé). Détection des FAUX changements de colonne (demande Matt) : une entrée dans une étape ne compte que si le lead y reste ≥ MIN_DWELL_MIN (120 min, ajustable) OU progresse vers une étape plus avancée (RDV pris → Proposition = vraie avancée, compte ; RDV pris → retour Contacté en 5 min = erreur d'attribution, ne compte pas). Attribution = assignee_id courant ; dédup 1 par lead/semaine. Testé unitairement (scénarios vrai RDV / faux RDV corrigé / RDV qui progresse → seuls les 2 vrais comptés ; leads parlés = seulement le lead manual). Frontend : onglet Objectifs (nav + objectifs dans COORD_VIEWS + applyGating canCoord()), renderObjectifs = sélecteur de semaine (préc./cette semaine/suiv.) + table agents × {obj éditable / réel} pour Leads, RDV, Ventes (réel vert si ≥ objectif, rouge sinon), légende explicative. api.js goals()/setGoal(). i18n ES/FR/EN (nav.objectifs, obj.*). Accès admin + Gary. Cache-bust ?v=20260825goals, SW moa-crm-v48-20260825goals. Testé Playwright (admin) : onglet visible, 11 agents, semaine 24-30/08, édition d'un objectif → persistée. Restart moa-crm fait.
  • - 2026-08-24 : Rôle coordinateur (Gary) : vue réduite (Coordination + Ventes MOA + Commissions + Promoteurs + WhatsApp). Demande Matt : masquer pour Gary Pipeline, Prospects (leads), Tâches, Assistant, Biens (properties) et Ventes (le ventas du coordinateur, pas Ventes MOA). frontend/js/app.js : nouveau coordOnly() = isCoordinator() && !isAdmin() + liste COORD_VIEWS = ['coord','ventesmoa','commissions','promoteurs','whatsapp']. (1) applyGating : pipeline/leads/tasks/assistant/properties/ventas passent à !co ; coord reste canCoord() (Gary GARDE la Coordination). (2) defaultView du coordinateur passe de coord à ventesmoa. (3) garde dans navigate : if (coordOnly() && !COORD_VIEWS.includes(v)) v = 'ventesmoa' (impossible d'atterrir sur un onglet masqué). N'affecte QUE le coordinateur (admins et agents inchangés, co=false). NB (correctif même jour) : la Coordination avait d'abord été masquée puis REMISE — c'est là que Gary inscrit des prospects (bouton « + Nouveau lead », leads intake_source='coordinateur_manual'), donc indispensable. Cache-bust ?v=20260824garycoord, SW moa-crm-v47-20260824garycoord. Testé Playwright (compte Gary) : onglets visibles = Promoteurs + Coordination + Ventes MOA + Commissions (WhatsApp masqué car wa_enabled=false), défaut = Ventes MOA, Coordination ouvre avec « + Nouveau lead », clic forcé sur un onglet masqué → renvoi sur Ventes MOA.
  • - 2026-08-24 : Rapatriement du suivi des ventes (ex-app "ventes-moa") DANS le CRM, onglets Ventes MOA + Commissions, accès admin + Gary (coordinateur). Demande Matt : tout au même endroit, agents exclus. Choix (validé) : proxy (le CRM absorbe l'UI, le moteur ventes reste un service interne moa-ops sur 127.0.0.1:8338 que le CRM pilote), + fermeture de l'URL publique ventes-moa.panelbay.com (301 → CRM). Backend (server.js) : nouveau moaOpsSend(method, path, body, actingName) + proxy générique app.use('/api/moaops', auth, requireCoordinatorOrAdmin, …) qui relaie TOUTE l'API moa-ops (/api/moaops/<x> → moa-ops /api/<x> : ventes board/meta/fiche/édition, dossiers meta/summary/events, commissions liste/agents/pay/topseller) via X-Internal-Key, en transmettant l'identité réelle via X-Internal-User (attribution correcte des événements de suivi côté moa-ops). Les 2 anciens endpoints /api/admin/moa-ventes et /api/admin/moa-commissions passent de requireAdmin à requireCoordinatorOrAdmin. Frontend : applyGating + navigate (redirect ligne « ventesmoa/commissions ») + renderView passent de isAdmin() à canCoord() (admin + coordinateur). renderVentesMoa : board cliquable → fiche interactive portée de moa-ops (stepper d'avancement du statut + timeline du dossier add/delete + édition des 14 champs), overlay scopé .mvt-scope (module openMvtModal/mvtRenderStepper/mvtLoadTimeline/mvtSaveField, appels via API.moaops). renderCommissions : lien externe retiré + action « Marquer payé » sur les lignes à payer (mvtPayCommission → POST /moaops/commissions/:id/pay). api.js : moaops(method, path, body). i18n ES/FR/EN (vm.*, cm.markPaid/cm.pay*). CSS scopé append (.mvt-ov/.mvt-scope/.mvt-annx, alias de variables MOA). Cache-bust ?v=20260824opsmig4, SW moa-crm-v45-20260824opsmig4. Testé Playwright de bout en bout (compte Gary coordinateur) : onglets Ventes MOA + Commissions visibles ; board 101 lignes ; fiche → stepper 6 étapes (courante en vert MOA), 14 champs, 8 jalons, timeline ; ajout d'événement attribué à « Gary Jean-Louis » (X-Internal-User OK) puis supprimé ; commissions 4 KPI + 18 lignes + bouton « Marquer payé ». Contrôle d'accès vérifié côté API : admin 200, Gary 200, agent 403 (lecture ET écriture). moa-ops.service garde son rôle de backend interne (X-Internal-User ajouté à requireTeam). Restart moa-crm + moa-ops faits.
  • - 2026-08-14 : Doublons : plus de faux positif "même nom de famille" quand email ET téléphone diffèrent. Retour Matt : le CRM rapprochait « Jean Michel » et « Michel » (lien FAIBLE via nom de famille) alors qu'ils ont des emails ET des téléphones différents (donc quasi certainement deux personnes distinctes). Correctif dans backend/server.js buildDupIndex() : nouveau garde-fou identifiersConflictD(a,b) = vrai si email renseigné des DEUX côtés mais différent, OU téléphone renseigné des deux côtés sans aucun numéro commun. Le lien faible via:'surname' n'est plus créé quand identifiersConflictD est vrai (les liens FORTS email/tél, eux, restent inchangés : un même email/tél continue de rapprocher). Effet : les 4 fiches « Michel » réelles (tous emails/téls différents) ne se soupçonnent plus entre elles ; un vrai doublon potentiel (ex. « Eric Bourdier » avec contact vs « Bourdier » sans contact renseigné) reste bien signalé. Testé sur les données réelles (identifiersConflictD = true pour tous les couples Michel), node --check OK, restart fait, endpoint 401 sans token, aucun log d'erreur. Ne touche pas la détection forte ni la règle "surname ne fusionne jamais un cluster".
  • - 2026-08-14 : Inférence des langues améliorée : on détecte la langue du MESSAGE écrit par le prospect (signal prioritaire). Retour Matt : des prospects ayant écrit un message explicite EN ANGLAIS étaient affichés « anglais < 100% », illogique. Cause : la 1re version avait volontairement ignoré le texte des notes (l'en-tête du template d'ingestion est en FR, il polluait tout). Correctif dans backend/langinfer.js : (1) extractMessage(notes) isole le vrai message du prospect, soit via un marqueur (Message :, Mensaje :, Mensagem, Nachricht, Messaggio, Msg, Commentaire...), soit en retirant les lignes de template connues (« Lead... », « Pays : », coordonnées) ; (2) detectLang(text) = détecteur multilingue par mots distinctifs (en/es/fr/pt/de/it, accents retirés) + cyrillique → ru, avec exigence d'un vainqueur net (≥3 mots distinctifs et marge ≥2 et ≥5 mots = « strong » ; ≥2 et devant = « medium ») ; (3) branché en 2e priorité (juste après le champ language explicite, avant pays/téléphone/source) : message clairement dans une langue isolé via marqueur ⇒ confiance 100 (certain, drapeau sans %) ; message court/moins net ⇒ 88% ; fallback sans marqueur ⇒ 82% si net. La nationalité (pays) reste en langue secondaire avec %. Effet : les prospects Ladislas (canal anglophone, message après « Message : ») passent de « en ~55-96% » à « en » certain, avec par ex. 🇮🇹/🇩🇪 en secondaire (leur pays). Testé sur les 67 leads à assigner (37 en anglais désormais certains) + tests unitaires FR/ES/DE/EN/RU/EN-court (chaque langue correctement détectée, message court = 88% donc pas sur-affirmé, aucune note = retombe sur le pays). node --check OK, restart fait, endpoint 401 sans token, aucun log d'erreur. La règle « ne jamais détecter la langue sur TOUT le texte de la note (en-tête FR = template) » est documentée en tête du module.
  • - 2026-08-14 : Vue "Leads à assigner" (dashboard admin) : nouvelle colonne "Langue" (drapeau + degré de fiabilité), multi-langues. Demande de Matt : savoir d'un coup d'œil quelle langue parle un prospect à assigner ; si l'info n'est pas certaine, afficher un pourcentage de fiabilité sous le drapeau ; un prospect peut avoir plusieurs langues. NOUVEAU module backend backend/langinfer.js (ESM, inferLanguages(lead) → [{code, confidence, certain}], codes es/fr/en/pt/ru/de/it, max 3 langues, trié par fiabilité). Croise, du plus fiable au moins fiable : (1) champ language explicite → certain (100, pas de %) ; (2) residence_country → ~85 ; (3) pays déclaré en clair dans les notes ("Pays : Italy/USA…", fréquent sur les leads ingérés Ladislas) → ~82 ; (4) indicatif téléphonique international (phone/whatsapp, ex +39→it, +49→de, +1→en, +595→es) → ~72 ; (5) canal d'acquisition (source/tags : Ladislas / The Wandering Investor = audience ANGLOPHONE → en, pas fr) → ~55. Deux signaux concordants remontent la fiabilité (cap 96 ; seul l'explicite vaut 100). Pièges évités : NE PAS déduire la langue du texte des notes (template rédigé en FR par le système, pas par le prospect → polluait tout en fr) ; NE PAS mapper une source francophone-agence vers fr. Numéro local (préfixe 0, pas d'indicatif) → ignoré. BACKEND server.js : /api/dashboard unassignedList sélectionne en plus whatsapp, intake_source, residence_country, language, calcule languages par lead et n'expose pas ces signaux bruts. FRONTEND app.js : helper langCell(l) (drapeaux emoji ; sous chaque drapeau non-certain, une pastille % colorée high/med/low ; tooltip = nom de la langue + "confirmé" ou "fiabilité N%" ; ? si aucun signal) + nouvelle colonne insérée entre "Contact" et "Date" (7 colonnes / 7 td). i18n th.language, lang.unknown/confirmed/reliability (ES/FR/EN, règle i18n respectée). CSS .lang-cell/.lang-badge/.lang-emoji/.lang-conf/.lang-unknown + .lang-unknown. Cache-bust styles.css/i18n.js/app.js ?v=20260814lang. Restart fait, node --check OK sur server.js/langinfer.js/app.js/i18n.js. Testé sur les 70 vrais leads à assigner : 40/70 obtiennent ≥1 langue (distribution en:39, de:5, fr:4, ru:3, es:2, it:1) ; ex. lead Italie (+39, "Pays: Italy") → it 90% + en 55% (canal anglophone) ; USA/Pays-Bas → en 96% ; Suisse → fr 90% + de 70% ; sans signal → ?. Endpoint : 401 sans token, aucun log d'erreur. NB : email admin réel = matt.bouthors@gmail.com (le matt@moarealestate.com du §1 est périmé).
  • - 2026-08-14 : Connexion MCP claude.ai — diagnostic + contournement OAuth câblé (URL directe par agent). Symptôme rapporté (Matt + agents) : dans claude.ai, après le bon mot de passe sur la page de login MCP, « ça charge longtemps puis rien ». Diagnostic sur données réelles : la table mcp_oauth_codes contenait 48 codes d'autorisation créés pour seulement 2 tokens émis (mcp_oauth_tokens), c.-à-d. le login réussit et un code est minté à chaque fois, mais claude.ai n'appelle quasi jamais /token pour l'échanger (fragilité connue des connecteurs personnalisés claude.ai). Vérifié que le serveur est sain : métadonnées .well-known/oauth-authorization-server (issuer https://crm-moa.panelbay.com/, authorization_response_iss_parameter_supported:true) et .well-known/oauth-protected-resource/mcp (JSON correct, pointé par le header WWW-Authenticate du 401) OK ; le flux a abouti au moins une fois (2 tokens, appels whoami/pipeline_summary réussis). Cause probable #1 restant à confirmer côté client : plan Claude gratuit (les connecteurs personnalisés exigent Pro/Max/Team) — symptôme identique et déjà documenté dans workspace/moa-crm-mcp-connecter-claude.md. Correctif livré côté serveur : le contournement /mcp-direct/<secret> (défini dans mcp/index.js + mcp/store.js mais jamais câblé, utilisé une seule fois à la main pour Monica) est désormais exploitable via un nouvel outil CLI backend/mcp_direct.mjs (create <email> [label] / list / revoke <email|secret>) qui génère une URL https://crm-moa.panelbay.com/mcp-direct/<secret> à coller telle quelle dans le connecteur (le secret EST le credential, lié à l'uid, révocable ; scoping serveur par uid identique au flux OAuth, donc un agent ne voit que son portefeuille ; l'URL renvoie 404 si révoquée, jamais 401, pour ne pas déclencher la découverte OAuth). Testé de bout en bout : handshake initialize = HTTP 200 + session MCP valide + capacités outils, en local (127.0.0.1:8310) ET via l'HTTPS public (nginx). Rappel : l'URL directe supprime l'étape OAuth qui plante, mais n'exonère PAS du plan payant (ajouter un connecteur personnalisé reste réservé à Pro/Max/Team). URL admin générée pour Matt (label « Admin Matt »). Aucun restart nécessaire (outil hors process serveur ; les routes /mcp-direct/* existaient déjà). Doc agent workspace/moa-crm-mcp-connecter-claude.md complétée (méthode URL directe en dépannage).
  • - 2026-08-13 : Doublons — les suspects "nom de famille" RÉAPPARAISSENT dans le panneau, en PAIRES (correctif du correctif). Le change précédent (retrait des liens surname du regroupement) avait fait disparaître les suspects par nom de famille du panneau « doublons à résoudre » (ex. les Garcia n'apparaissaient plus du tout), ce que Matt ne voulait pas. Nouveau comportement (/api/duplicates, server.js) : après les clusters forts (composantes connexes email/tél/nom complet), on émet chaque lien via='surname' comme une paire distincte {surnameSuspect:true, size:2} (dédupliquée, sautée si déjà ensemble dans un cluster fort). Résultat : « Garcia » réapparaît en 2 paires séparées (Garcia + Claudia, Garcia + Abilio), et Claudia/Abilio ne sont jamais dans le même bloc. FRONT (app.js) : libellé « (même nom de famille ?) » pour ces paires ; tri = dangereux (≥2 agents) > forts > suspects nom > gros. Cache-bust app.js ?v=20260813duppairs. Restart fait, node --check OK. Testé /api/duplicates : 2 clusters Garcia (tous deux SUSPECT nom, en paire avec « Garcia » seul), aucun bloc ne réunit Claudia+Abilio. NB : dupClusters (compteur dashboard) n'est pas affiché côté front, le panneau compte directement clusters.length.
  • - 2026-08-13 : Doublons — le lien faible "nom de famille" ne fusionne plus un cluster transitivement (fix Claudia/Abilio Garcia). Demande de Matt : « Garcia » peut être un doublon d'« Abilio Garcia », mais « Claudia Garcia » ne doit PAS être un doublon d'« Abilio Garcia » (prénoms bien remplis et différents). Le lien PAIRWISE était déjà correct (firstNamesCompatible : Claudia↔Abilio jamais liés). Le défaut était dans la vue « doublons à résoudre » (/api/duplicates, composantes connexes) : le hub sans prénom « Garcia » reliait transitivement Claudia ET Abilio dans un même cluster, donnant l'impression qu'ils étaient doublons. FIX (server.js) : les deux parcours de composantes connexes (compteur dupClusters du dashboard + /api/duplicates) ne suivent plus les arêtes via==='surname' (seuls email/tél/nom complet fusionnent un cluster). Les suspects par nom de famille restent affichés en badge pairwise sur chaque fiche (flux « à assigner »), là où Matt en a besoin ; ils ne polluent plus le panneau de fusion. Restart fait, node --check OK. Testé : Garcia(seul) → suspect de Claudia ET Abilio (badge, normal) ; Claudia Garcia → suspect de « Garcia » seul (PAS Abilio) ; Abilio Garcia → suspect de « Garcia » seul (PAS Claudia) ; /api/duplicates = aucun cluster ne réunit Claudia+Abilio (5 clusters, tous forts/nom complet). NB : conséquence assumée, une paire uniquement liée par nom de famille (ex. Bourdier ↔ Eric Bourdier) n'apparaît plus dans le panneau « doublons à résoudre » mais reste signalée sur la carte du lead à assigner.
  • - 2026-08-13 : Détection de doublons renforcée : nouveau niveau « suspect » par NOM DE FAMILLE (fiches à peine remplies). Demande de Matt (exemple : « Eric Bourdier » nouveau lead quasi vide, pas détecté comme doublon de « Bourdier » déjà traité/perdu chez Mathieu). Avant, le rapprochement par nom exigeait un NOM COMPLET identique (« eric bourdier » ≠ « bourdier »). Ajout d'un 3e niveau, le plus FAIBLE, via='surname' : deux leads partagent le dernier mot du nom (>=3 lettres). Garde-fous anti-bruit cumulatifs (buildDupIndex, server.js) : (1) au moins une des deux fiches a un nom complet (>=2 mots), sinon deux prénoms seuls identiques (« Michel »/« Eric » entrés en un mot) se faux-rapprochaient ; (2) prénoms compatibles (l'un sans prénom, égaux, ou l'un préfixe de l'autre) → « Eric Bourdier » vs « Marie Bourdier » ne sortent PAS ; (3) patronyme pas trop courant (ignoré si partagé par > 6 fiches, ex. « David »). Rang de fiabilité VIA_RANK (email/tel=2 > nom complet=1 > nom de famille=0) : on garde toujours le lien le plus fort. strong = email/tél uniquement (le nom de famille n'est JAMAIS « fort »). Le suspect par nom ne bloque PAS l'attribution : les deux garde-fous 409 (duplicate_assign) filtrent désormais explicitement via==='email'||'phone' (avant via!=='name', ce qui aurait laissé surname bloquer). FRONT : DUP_VIA.surname='même nom de famille' (app.js), badge faible existant réutilisé. Cache-bust app.js ?v=20260813dupsurname. Restart fait, node --check OK. Testé : Eric Bourdier → suspect via=surname avec « Bourdier » (perdu, chez Mathieu), strong=false ; mesure de bruit sur les 663 leads = 19 paires suspectes au total (règle resserrée), toutes plausibles (fiche pauvre ↔ fiche complète même patronyme). NB : signal d'aide à la vigilance, pas une vérité ; le bouton « pas un doublon » (dup_dismissed) écarte un faux positif définitivement.
  • - 2026-08-13 : Connecteur MCP — accès DIRECT sans OAuth (URL secrète par agent), contournement du bug claude.ai. Comme claude.ai ne rappelle jamais /token (bug 100% côté eux, prouvé : cf. entrée iss ci-dessous + simulation serveur complète 5/5), on supprime l'OAuth pour les agents : chaque agent reçoit une URL /mcp-direct/<secret> à coller dans son connecteur claude.ai. Pas d'OAuth = l'étape qui plante disparaît. Le secret EST le credential (haute entropie, lié à un uid, révocable) ; le scoping serveur par uid est IDENTIQUE au flux OAuth (un agent ne voit que son portefeuille). STORE (mcp/store.js) : table mcp_direct_tokens (secret PK, uid, label, revoked, created_at, last_used_at) + createDirectToken/getDirectToken/touchDirectToken/listDirectTokens/revokeDirectToken. ENDPOINT (mcp/index.js) : POST/GET/DELETE /mcp-direct/:secret (registre de sessions séparé, même buildMcpServer(ctx) scopé). Renvoie 404 (jamais 401) sur secret invalide/révoqué, pour ne PAS déclencher de découverte OAuth côté client. Restart fait, node --check OK. Testé bout-en-bout via l'URL publique (comme le ferait le Claude de l'agent) : mauvais secret → 404 ; initialize → 200 + session ; tools/list → 6 outils ; whoami → Monica Ayala ; pipeline_summary → 84 leads (SON portefeuille, pas 663) = cloisonnement OK. Jeton de Monica (uid KQWg7q8NywGjHS7h) créé. Reste à confirmer : que claude.ai WEB accepte bien un connecteur custom SANS OAuth (à tester en collant l'URL ; se teste depuis n'importe quel compte claude.ai, l'accès est scopé au secret). Minting actuel = script manuel (piloté par l'AIOS) ; UI admin de gestion (créer/révoquer par agent) = à ajouter si besoin. NB sécurité : l'URL = un secret, à transmettre en privé ; révocation = revokeDirectToken(secret) (ou UI future).
  • - 2026-08-13 : Connecteur MCP : correctif OAuth iss (RFC 9207) — cause probable du "ça charge 1 s puis stoppe". Diagnostic par logs nginx : le vrai flux claude.ai faisait /authorize → /mcp/login → POST /mcp/login 302 (login OK, code émis) puis jamais POST /token (33 logins OK, 0 token échangé), alors qu'un flux simulé node allait jusqu'à /token 200. Donc claude.ai recevait le code au callback et n'échangeait jamais. Cause : notre page de login (mcp/index.js, POST /mcp/login) fabriquait la redirection à la main avec seulement code+state, sans le paramètre iss que l'OAuth 2.1 (RFC 9207) impose dans la réponse d'autorisation ; un client strict comme claude.ai rejette alors le callback en silence. FIX (2 volets, mcp/index.js) : (1) la redirection de succès ajoute iss = issuerUrl.href (= https://crm-moa.panelbay.com/, slash final, exactement l'issuer publié) ; (2) middleware placé AVANT mcpAuthRouter qui enveloppe res.json sur /.well-known/oauth-authorization-server pour injecter authorization_response_iss_parameter_supported: true (la RFC dit qu'un client ne valide iss que si l'AS l'annonce). Restart fait, node --check OK. Testé : (a) redirect /mcp/login (user jetable) porte désormais code+state+iss=https://crm-moa.panelbay.com/ ; (b) métadonnée live renvoie authorization_response_iss_parameter_supported:true. À CONFIRMER côté claude.ai en RETIRANT puis RÉAJOUTANT le connecteur (pour forcer une nouvelle découverte de métadonnée + DCR). Si ça ne suffit pas, repli = assistant IA intégré / passage sur agrégateur d'API (OpenRouter).
  • - 2026-08-13 : Alerte WhatsApp d'attribution = message simple sans aperçu de lien (plus d'image auto). Demande de Matt : le message automatique « te asignaron un nuevo lead… » se termine par l'URL du CRM, ce qui déclenchait sur WhatsApp un aperçu de lien avec une image par défaut. wa_evolution.js : sendText(name, number, text, opts) accepte désormais opts.linkPreview ; quand false, le body Evolution porte linkPreview:false (pas d'aperçu). server.js : notifyAssignment appelle wa.sendText(..., { linkPreview:false }). Texte du message inchangé. Restart backend fait, node --check OK (server.js + wa_evolution.js). NB : n'affecte que l'alerte d'attribution ; les autres envois WhatsApp gardent l'aperçu par défaut.
  • - 2026-08-13 : Arbitrage + fusion des doublons depuis le dashboard admin (bouton par fiche). Demande de Matt (« un bouton pour attribuer chaque doublon à un agent plutôt qu'un autre ; avant d'éliminer le prospect chez le perdant, récupérer les infos des 2 fiches pour les fusionner »). Dans le panneau « Doublons à résoudre », chaque fiche d'un cluster a désormais un bouton vert « ✓ Attribuer à <agent> » (ou « Garder cette fiche » si non assignée). Cliquer = cette fiche SURVIT (et le lead revient à son agent), les autres sont fusionnées puis supprimées, sans perte de données. BACKEND (server.js) : nouveau POST /api/duplicates/merge (auth+requireAdmin, body {keepId, mergeIds[], assigneeId?}). Fusion : (1) champs vides de la fiche gardée comblés depuis les perdantes (email, phone, whatsapp, source, budgets, timeline, buyer_type, zone, motivation, language, odoo_lead_id, etc. ; jamais d'écrasement d'une valeur déjà présente) ; (2) tags = union ; (3) stage = le plus avancé (rang STAGE_RANK ; perdu seulement si TOUTES perdues) ; (4) first_contact_at = le plus ancien ; (5) assignee_id = agent choisi (override possible) ; (6) notes = notes de la gardée + bloc « 🔀 Fusion doublon (date) » listant chaque fiche intégrée (nom, agent, étape, source, contact, ses notes) → rien n'est perdu ; (7) ré-attribution de toutes les tables liées vers la fiche gardée AVANT suppression : activities, ventas, wa_messages, lead_catalog_promoters, lead_catalog_units, lead_properties (via UPDATE OR IGNORE pour éviter les collisions de PK, puis purge des restes) ; (8) suppression des perdantes ; (9) activité tracée sur la gardée. Le tout dans une transaction. FRONTEND : app.js (bouton .dup-keep par membre, confirm récapitulatif, renderDashboard au retour), api.js (mergeDuplicate(keepId, mergeIds, assigneeId)), i18n dup.* (ES/FR/EN), CSS .dup-mem (flex) + .dup-keep (vert forest, poussé à droite). Cache-bust styles.css/i18n.js/api.js/app.js ?v=20260813dupmerge (⚠ api.js avait un cache-bust indépendant assign1 non bumpé par les changements précédents : c'est ce qui faisait échouer le 1er test du clic — API.mergeDuplicate absente car ancien api.js servi ; corrigé en alignant les 4 assets sur dupmerge). Restart backend fait, node --check OK (server/app/i18n). Testé en profondeur sur des fiches jetables (supprimées après, base laissée propre) : (a) endpoint direct (token admin forgé) → B supprimée, email/notes rapatriés, étape la + avancée gardée, 1er contact le + ancien, agent appliqué, activités repointées (A+B+trace), notes = bloc fusion + notes des 2 ; (b) fusion multi-fiches (2 perdantes) via API → 1 seule fiche restante chez Seb ; (c) clic bouton en live (Playwright) → 1 fiche restante chez Seb, rdv_pris conservé, email conservé, notes fusion+noteA+noteB. NB : après fusion les perdantes n'existent plus (pas besoin de dup_dismissed). Le bloc doublon reste hardcodé FR pour l'en-tête (« Doublons à résoudre », « Pas un doublon ») ; les nouveaux libellés d'arbitrage sont i18n.
  • - 2026-08-13 : En-têtes triables (clic) sur la file « Leads à assigner » du dashboard admin. Demande de Matt (« un bouton en haut de chaque colonne pour trier selon date ou % closing »). Les en-têtes Prospect / Source / % closing / Date sont désormais cliquables (tri client instantané, re-clic pour inverser le sens, flèche ▲/▼ indiquant la colonne + le sens actifs). Défaut inchangé = Date décroissante (plus frais en haut). Règles de tri : % closing = numérique, les « ? » (sans score) toujours en bas quel que soit le sens ; Date = COALESCE(first_contact_at, created_at) ; Prospect/Source = alphabétique (localeCompare). FRONTEND (app.js) uniquement : le bloc de rendu de unassignedList a été refactoré (fonction rowFor(l) + repaint() qui re-trie le tbody sans refetch ; sortState {key,dir} ; defDir = sens par défaut par colonne). La colonne e-mail/téléphone a été relabellisée « Contact » (avant : « Activité », trompeur) et la colonne date reçoit un vrai en-tête « Date » (avant : vide). i18n th.contact + th.sortHint (ES/FR/EN ; th.date réutilisé). CSS th.th-sort (curseur, hover forest, focus gold, flèche .th-arrow dorée). Cache-bust styles.css/i18n.js/app.js ?v=20260813sort. Zéro changement backend/DB. node --check OK (app.js + i18n.js). Testé Playwright (token admin forgé, nettoyé après) : 4 en-têtes triables détectés ; tri % closing → 90/88/88/85… en haut, « ? » en bas ; tri Date → récent (2026-08-12) vs ancien (28/06/2025) selon le sens, flèche qui bascule.
  • - 2026-08-13 : Reprise des 33 leads de Diego Vicente (agent parti) → tous attribués à Sébastien Beaupied, avec résumé de passation par lead. Demande de Matt (« reprends les leads de Diego, attribue-les à Seb, y compris gagnés et perdus, et fais un résumé de passation pour chaque »). Contexte : Diego a quitté l'entreprise ; 39 de ses leads avaient été identifiés via les logs Odoo (mail.tracking.value), dont 6 déjà migrés/travaillés par d'autres agents (NON touchés) et 33 restés bloqués dans Odoo (11 vivants, 3 Won, 19 Lost). Procédé (scripts jetables, supprimés après) : (1) fetch Odoo complet des 33 (crm.lead : name, contact_name, email, phone, stage, create_date, description=historique Diego) via lib/odoo_client ; (2) génération IA en batch (CLI claude -p) d'un résumé de passation FR par lead (profil, budget, projets présentés, où en est la relation, prochaine étape ; pour un Lost : pourquoi c'est mort + si relançable) ; (3) import Node dans data/moa-crm.db. Mapping étapes Odoo→CRM : Won→gagne, Lost→perdu, Qualified→interesse, Contacted→contacte. Chaque lead créé avec assignee_id=Seb (cZE1hBEFXeQjHjV7), source='Repris de Diego', intake_source='diego_reassign', odoo_lead_id (traçabilité + anti-doublon), first_contact_at=date Odoo d'origine, language (heuristique indicatif/TLD), et notes = en-tête « 🔁 Repris de Diego » + « 📋 Passation : <résumé> » + « 🗒 Historique Diego : <log brut> » ; plus une activité tracée sur chaque fiche. Résultat vérifié en base : 33 importés, 0 doublon, 33/33 chez Seb (contacte 1, interesse 10, gagne 3, perdu 19). Backup DB avant écriture : data/moa-crm.db.bak-20260813-115525-prediego. NB : les gagnés/perdus sont mis chez Seb pour réactivation future (revente / relance), comme demandé ; les 6 leads déjà travaillés par d'autres agents n'ont PAS été réattribués (à confirmer avec Matt s'il veut aussi les basculer). Récap source : /root/workspace/MOA-leads-Diego-Vicente.md.
  • - 2026-08-13 : Colonne « % closing » (probabilité de conclure, estimée par l'IA) dans la file des leads à assigner. Demande de Matt (« sur les leads à attribuer, mets un % de closing selon les infos qu'on a, et un point d'interrogation si pas d'info »). Un score IA lit les SEULES infos qu'on a sur le prospect (source + notes + champs de qualif) et estime la proba d'achat. Constat data : les champs structurés (budget/timeline/type...) sont quasi tous vides, la vraie info est dans notes ; beaucoup de leads Ladislas ont une note générique sans rien de qualifiant → pour ceux-là le score reste NULL → affiché « ? » (règle de Matt), un simple filtre mots-clés serait inutile ici, d'où le choix IA. DB (db.js) : 5 colonnes idempotentes sur leads — close_score (0..100, NULL=« ? »), close_label (hot|warm|cold|unknown), close_reason (justif. courte FR), close_scored_at, close_fingerprint (empreinte des infos scorées, pour ne re-scorer que si l'info change). SCORER (backend/close_scorer.mjs, Node autonome, écrit direct dans data/moa-crm.db, raisonnement via CLI claude -p — jamais l'API) : sélectionne les leads à assigner non-scorés ou modifiés, les envoie par batch de 18 avec une rubrique (hot 70-100 signal d'achat fort / warm 40-69 intérêt réel / cold 1-39 faible-négatif / unknown=NULL si rien de qualifiant), parse le tableau JSON, écrit les scores. Flags --all (re-score tout) et --limit N. Wrapper backend/run_close_scorer.sh + **cron */20 * * * *** (incrémental, coût quasi nul quand rien de neuf ; log /root/aios/logs/moa-crm-close-scorer.log). BACKEND (server.js) : unassignedList (dashboard admin) renvoie close_score/close_label/close_reason ; /api/leads/queue (coordinateur) fait déjà SELECT *. FRONTEND (app.js) : closeChip(l) = puce colorée % + tooltip = raison (« ? » gris quand NULL), ajoutée en colonne « % closing » dans le tableau admin « Leads à assigner » ET la file coordinateur coordLeads. i18n th.closeScore + close.hot/warm/cold/unknown (ES/FR/EN). CSS .close-chip (hot vert / warm ambre / cold rouge / unknown neutre). Cache-bust styles.css/i18n.js/app.js ?v=20260813close. Restart fait (migration appliquée, 5 colonnes vérifiées), node --check implicite (service actif). Scoré en live les 72 leads à assigner : 17 hot, 24 warm, 13 cold, 18 « ? » ; ex. Eric Bourdier 90% (unité précise + plan de paiement), Denis Peinlich 88% (vient la semaine prochaine, 2 unités), SOHEILA 15% (ferme rurale hors cible). Testé Playwright (token admin forgé, nettoyé après) : colonne affichée, code couleur OK, « ? » sur les leads Ladislas boilerplate. NB : score = aide à la priorisation, pas une vérité ; il se met à jour tout seul via le cron quand un nouveau lead arrive ou qu'une note change.
  • - 2026-08-13 : Alerte WhatsApp automatique quand un lead est attribué à un membre du répertoire. Demande de Matt (« quand j'attribue un lead à qqn de mon équipe qui est dans mes contacts, envoie-lui une alerte WhatsApp »). DB (db.js) : ALTER TABLE team_members ADD COLUMN user_id TEXT (migration idempotente) = lien explicite optionnel contact→agent CRM. BACKEND (server.js) : (1) nameMatches(contact, user) rapproche un contact d'un agent par prénom égal OU diminutif (préfixe ≥ 3 lettres, ex. « Facu » ↔ « Facundo Roman ») + noms de famille du contact inclus chez l'agent ; (2) resolveContactForUser(user) = lien explicite user_id d'abord (fiable), sinon repli par nom NON ambigu (un seul contact matche, sinon rien = pas d'envoi au mauvais numéro) ; (3) pickSenderInstance(actingUserId) = instance Evolution open de celui qui attribue, repli sur n'importe quelle instance open ; (4) notifyAssignment(lead, prevAssigneeId, actingUser) : ne fait rien si l'attribution n'est pas nouvelle ou si l'assigné = l'acteur (pas d'auto-alerte) ; sinon résout le contact + l'instance, renvoie {to, phone} en synchrone et envoie en tâche de fond (setImmediate + wa.sendText) un message ES (« Hola X, te asignaron un nuevo lead… » + nom/tel/source du lead + lien CRM), puis trace une activité sur le lead (« 🔔 Alerte WhatsApp envoyée à … » ou « ⚠ … non envoyée : <err> »). Câblé dans PATCH /api/leads/:id (sur changement d'assignee_id) ET POST /api/leads (lead créé déjà assigné). Endpoints team-contacts GET/POST/PATCH gèrent user_id (validé via cleanUserId, id existant sinon null). La réponse PATCH/POST porte wa_alert → toast front « 🔔 Alerte WhatsApp envoyée à X ». FRONTEND (app.js) : sélecteur « Agent CRM lié » dans la modale de contact (liste state.users, option « — Aucun — ») + aide, user_id envoyé dans le body ; capture de la réponse updateLead pour le toast. CSS .team-form .field-hint. Cache-bust ?v=20260813assign1. Restart fait, node --check OK (server/app/db), migration user_id vérifiée. Testé : (a) intégration via token admin forgé + contact lié à Facundo Roman + numéro bidon +000000000000 (aucun vrai destinataire) → PATCH renvoie wa_alert.to, activité « envoyée » tracée, tout nettoyé ; (b) unitaire nameMatches (8 cas) : Facu↔Facundo, Monica↔Monica Ayala, Ale↔Alexis, Seb↔Sébastien = true ; Ab↔Abel, Gary↔Facundo = false. NB : le contact existant « Facu » (sans lien) est déjà couvert par le repli par nom ; pour 100% de fiabilité Matt peut lier explicitement chaque contact-agent via le nouveau sélecteur. L'alerte part depuis le WhatsApp connecté de Matt (ou toute instance ouverte).
  • - 2026-08-13 : Ajout d'un contact au répertoire directement depuis une conversation WhatsApp. Demande de Matt (« depuis mes conversations WhatsApp je veux pouvoir créer un contact au répertoire, plus facile »). Jusqu'ici, pour ajouter un numéro inconnu au répertoire Contacts (équipe / fournisseur / promoteur), il fallait aller dans l'onglet Contacts, cliquer « + Contact » et retaper le numéro à la main. Maintenant, dans le fil de conversation WhatsApp (vue openThread de l'onglet WhatsApp), un bouton « + Répertoire » dans l'en-tête ouvre la modale de contact pré-remplie avec le numéro de la conversation et le push_name détecté. FRONTEND (app.js) uniquement, zéro changement backend/DB : teamContactForm(box, member, opts) accepte désormais un 3e argument opts (opts.prefill = {name, phone} pour pré-remplir en création ; opts.afterSave(r) pour remplacer le renderTeamInbox(box) par défaut, car ici la modale est ouverte depuis la box WhatsApp, pas la box Contacts → après ajout on rafraîchit le fil WhatsApp via openThread). Le bouton est ajouté dans l'en-tête du fil (après le bouton IA, à côté de « Créer un lead »), visible que le numéro soit déjà un lead ou non. Réutilise tout l'existant : API.addTeamContact rattache rétroactivement les messages du numéro (relinkTeamWa → toast « X message(s) rattaché(s) »), catégories/rôle/notes identiques. Cache-bust styles.css/api.js/app.js ?v=20260813rep1. Restart fait, node --check OK, HTTP 200. NB : le rattachement des messages au contact ne prend effet que si le numéro n'est PAS déjà un lead (le webhook attache un message à un lead OU à un contact, jamais aux deux) ; ajouter au répertoire un numéro déjà lead crée bien la fiche contact mais ses messages restent sur le lead.
  • - 2026-08-13 : Onglet « Équipe » renommé « Contacts » + catégories (équipe / fournisseur / promoteur / autre). Demande de Matt : l'onglet « Équipe » faisait doublon visuel avec « Agents » (rappel : Agents = utilisateurs CRM avec login/rôle/book ; Contacts = répertoire de numéros WhatsApp non-leads, objets différents, PAS fusionnés). Renommé le label nav en « Contacts » (data-view reste team). Élargi à un vrai répertoire : DB ALTER TABLE team_members ADD COLUMN category TEXT NOT NULL DEFAULT 'equipe' (migration idempotente). BACKEND (server.js) : TEAM_CATEGORIES=['equipe','fournisseur','promoteur','autre'] + cleanCategory() (valeur inconnue → equipe) ; POST/PATCH acceptent category ; GET trie ORDER BY category, name ; team-inbox/team-thread renvoient category. FRONTEND (app.js) : TEAM_CATS (label+couleur) ; vue « Contacts » avec chips de filtre par catégorie (Tous / Équipe / Fournisseurs / Promoteurs / Autres, comptés, seules les catégories non vides s'affichent), badge couleur de catégorie sur chaque ligne et dans l'en-tête du fil, sélecteur de catégorie dans la modale d'ajout/édition (champ « Rôle » renommé « Rôle / société »). CSS .team-chips/.team-chip/.team-cat en fin de styles.css. Cache-bust styles.css/api.js/app.js ?v=20260813team2. Gating INCHANGÉ : la vue reste réservée aux comptes avec le module WhatsApp actif (requireWa = admins + WA_ALLOWED_EMAILS, par défaut Monica), inbox scopée au compte WhatsApp connecté. Restart fait, node --check OK (4 fichiers), colonne category vérifiée. Testé (token admin forgé) : ajout category:'promoteur' OK ; category:'hacker' → repli equipe ; PATCH catégorie OK ; suppression OK. Contacts de test supprimés.
  • - 2026-08-13 : Nouvelle vue « Équipe » : suivi des messages WhatsApp échangés avec les membres de l'équipe (contacts internes), distincts des leads. Demande de Matt (« que le CRM détecte aussi les messages de mon équipe, ex. Facu »). Jusqu'ici le webhook Evolution ne rattachait un message qu'à un lead (match numéro samePhone) ; un message vers/depuis un coéquipier restait orphelin (lead_id NULL, invisible). AJOUT : notion de contacts internes. DB (db.js) : table team_members (id,name,phone,role,notes,created_at) + colonne wa_messages.team_member_id (migration idempotente, ALTER en try/catch duplicate) + index idx_wa_msg_team. BACKEND (server.js) : matchTeamMember(number) (même rapprochement STRICT samePhone que les leads, mais vers team_members ; matche dans les DEUX sens car p.number est toujours l'autre partie) ; relinkTeamWa(member) (rattache rétroactivement les orphelins ni-lead-ni-membre à la création/édition d'un contact) ; le webhook calcule team = lead ? null : matchTeamMember(p.number) et pose team_member_id sur le message inséré (via UPDATE par rowId après l'INSERT OR IGNORE). Endpoints, tous auth+requireWa : GET/POST/PATCH/DELETE /api/team-contacts (CRUD ; POST/PATCH renvoient linked = nb de messages rattachés ; DELETE détache les messages team_member_id=NULL puis supprime), GET /api/team-inbox (conversations groupées par membre, scopé user_id), GET /api/team-thread?member=<id> (fil + marque lus). Numéros normalisés via normalizePhone. FRONTEND : entrée nav team (« Équipe », icône ☏, dans index.html, visible si wa_enabled via applyGating+titles+dispatch renderView) ; renderTeam/renderTeamInbox/openTeamThread/teamContactForm (calqués sur l'inbox WhatsApp : liste des contacts avec badge non-lus + aperçu, fil lecture seule avec poll 8s, modale d'ajout/édition/suppression) ; méthodes api.js (teamContacts/addTeamContact/updateTeamContact/deleteTeamContact/teamInbox/teamThread) ; CSS .team-form/.wa-edit-btn en fin de styles.css. Cache-bust styles.css/api.js/app.js ?v=20260813team (i18n.js inchangé). Restart backend fait, node --check OK sur les 4 fichiers, migration DB vérifiée (table + colonne créées). Testé de bout en bout (token admin forgé) : ajout d'un contact au numéro d'un orphelin existant → linked:2, les 2 messages apparaissent dans team-inbox et team-thread ; suppression → contact retiré et messages redevenus orphelins (team_member_id NULL). Contact de test supprimé après coup. NB : la vue est gated par le module WhatsApp (requireWa) ; l'inbox équipe est scopée au compte WhatsApp connecté (comme l'inbox leads). Matt ajoutera ses contacts au fur et à mesure depuis le bouton « + Contact ».
  • - 2026-08-12 : Page « Mon compte » pour tous (agents inclus) : email de login visible + changement de mot de passe sécurisé + langue. Demande de Matt. Le bloc identité en bas de la sidebar (avatar + nom) devient cliquable (role=button, focusable clavier) et ouvre une modale « Mon compte » accessible à tous les rôles (agents standard compris, hors gating de navigation). Contenu : (1) en-tête identité (avatar, nom, titre/rôle) ; (2) Email de connexion affiché en lecture seule + bouton Copier (navigator.clipboard, repli execCommand) ; (3) Langue ES/FR/EN (réutilise window.I18N.LOCALES/FLAGS/NAMES, drapeau actif surligné ; au clic → API.setLocale + applyLocale, la modale se rouvre dans la nouvelle langue) ; (4) Changer mon mot de passe : champs mot de passe actuel + nouveau + confirmation, validation locale (≥ 6, correspondance) puis API.changePassword(new, current). Sécurité backend : POST /api/change-password exige désormais le mot de passe actuel (vérifié via bcrypt.compareSync) pour tout changement volontaire ; le premier login (mot de passe temporaire, must_change_password=1) en reste dispensé (l'utilisateur ne le connaît pas). Erreur wrong_current mappée à un message localisé côté front (acct.err.current). api.js : changePassword(password, current). Nouvelles clés i18n acct.* (ES/FR/EN, ES défaut). CSS : bloc identité cliquable (.who.acct-open, hover/focus) + modale (.acct-*). Cache-bust styles.css/i18n.js/api.js/app.js ?v=20260812e, SW moa-crm-v26-20260812e. Restart backend fait, node --check OK sur server.js/app.js/i18n.js/api.js. Testé : (a) logique mot de passe via utilisateur temporaire jetable (créé puis supprimé) — sans current → 400 wrong_current, mauvais current → 400, bon current → 200 et hash réellement mis à jour ; (b) rendu Playwright (compte agent Monica) : modale « Mi cuenta » affichée avec email monicaayala@moa-realestate.com, sélecteur de langue (Español actif), et les 3 champs de mot de passe (capture workspace/moa-crm-mon-compte.png). NB cosmétique : le libellé de rôle affiché vient du champ title en base (ex. « Agent Immobilier »), non traduit.
  • - 2026-08-12 : Email client OBLIGATOIRE à la déclaration de vente + création automatique du projet Airtable quand il n'existe pas. Deux demandes de Matt. (1) Email requis : le champ client_email était optionnel (la vente d'Alexis est partie sans email). Désormais exigé ET validé (format) des deux côtés. Backend server.js validateVenta : client_email ajouté à la liste need(...) + regex ^[^@\s]+@[^@\s]+\.[^@\s]+$ (rejet si mal formé). Frontend frontend/js/app.js : le champ passe required:true (astérisque) avec placeholder email@exemple.com, et collectMissing l'ajoute aux champs obligatoires + contrôle de format (le bandeau rouge « champs manquants » le liste). i18n : nouvelle clé vf.client_email_ph (ES/FR/EN). Cache-bust i18n.js/app.js ?v=20260812d, SW moa-crm-v25-20260812d. Testé : POST /api/ventas sans email → 400 validation_failed avec client_email dans les champs en erreur (token admin forgé). (2) Création auto du projet Airtable : lors du report vers « Unités vendues », si le projet vendu n'existe pas dans Projets Plan (cas « Marena Torre 6 », qui restait non lié → Matt devait le rattacher à la main), il est maintenant créé automatiquement puis lié. backend/airtable_sync.js : nouvelle fn resolveProject(v) (match par nom normalisé ; sinon POST dans Projets Plan avec Name=proyecto + Promoteur=promotor via typecast ; ajout au cache pour le même run) ; buildFields lie toujours le projet (trouvé OU créé) ; une note « Projet créé automatiquement (à compléter : promoteur, date de livraison…) » est ajoutée aux Commentaires quand il a été créé (au lieu de l'ancienne alerte « à créer/lier »). Testé : syncVenta sur une fausse vente avec projet inexistant → projet créé dans Projets Plan, lié dans la ligne « Unités vendues », unmatched.project=null ; vente + projet de test supprimés après coup. node --check OK (server.js, airtable_sync.js, app.js, i18n.js), service redémarré. NB : le contact client était déjà créé si absent (inchangé) ; désormais projet ET client sont auto-créés au besoin, seul l'agent reste résolu par nom sans création (liste d'agents volontairement fermée). L'ancienne vente d'Alexis n'est pas re-synchronisée (Matt a déjà rattaché son projet à la main) : le correctif vaut pour les ventes à venir.
  • - 2026-08-12 : Report automatique des ventes vers Airtable (« Unités vendues ») avec filet anti-perte 100%. Demande de Matt : que TOUTE vente déclarée dans le CRM apparaisse dans Airtable de façon fiable (Gary ne trouvait pas la vente d'Alexis du 11/08 car le CRM n'écrivait alors QUE dans ventas + email, aucune synchro Airtable). Cause du non-écriture historique : il n'existait aucun chemin de code CRM→Airtable, ET le jeton Airtable branché dans l'AIOS (AIRTABLE_API_KEY, patD…) est lecture seule ; Matt a fourni le jeton d'écriture « CRM MOA » (pat5…, portées data.records:read+write+schema.bases:read sur la base Moa). Piège résolu : dotenv n'écrase pas une variable déjà présente dans l'environnement, et l'AIOS expose AIRTABLE_API_KEY (lecture seule) dans l'env hérité → le jeton d'écriture est donc stocké sous un nom DÉDIÉ MOA_AIRTABLE_TOKEN dans backend/.env (jamais en collision). Nouveau module backend/airtable_sync.js : syncVenta(venta, agentName) mappe une ligne ventas → table Airtable « Unités vendues » (tblfIJdn0MB5dHDJ4, base appSVJQULMJNXy9Hl). Champs posés : Unité, Typologie (Mono/Hab1→"1 Hab"/Hab2→"2 Hab"/Hab3→"3 Hab"/Lock-Off, sinon Otro), Prix payé, Devise=USD, Statut de l'achat (reservado/en_espera/documentos/contrato_solicitado→"Contrat en Attente" ; contrato_recibido/firmado→"Entrega en Attente" ; comision_cobrada→"Commission recue"), Whatsapp/Email du client, Langues parlées (mapping idioma→Español/Ingles/Frances/Aleman/Portugues/Italiano/Neerlandes/Otro), Acheteur 2 Nom/Prénom si buyer2, et Commentaires (commentaire agent + traçabilité CRM MOA · vente #<id> + alertes de liens manquants). Liens résolus : Agent (lien vers Agents Immobilier par nom normalisé, cache 10 min), Projets immobilier (lien vers Projets Plan par nom ; si absent, laissé vide + note « projet à créer/lier » dans Commentaires), Nom client (lien vers Contacts : recherche par email exact puis par téléphone ≥8 chiffres, sinon création d'un contact minimal). typecast:true pour tolérer les libellés de select ; les IDs de liens sont passés explicitement (jamais de création auto de projet/agent). Câblage server.js : helper syncVentaToAirtable(vid) (recharge la vente, appelle syncVenta, persiste airtable_id sur succès ou airtable_error+airtable_attempts sur échec, trace un venta_event airtable_synced/airtable_failed) appelé non bloquant (setImmediate, après la réponse) à la création (POST /api/ventas) ET au changement de statut (PATCH /api/ventas/:id/status, met à jour la ligne Airtable existante). Migration db.js : 4 colonnes sur ventas (airtable_id, airtable_synced_at, airtable_error, airtable_attempts). Filet anti-perte (le cœur du 100%) : backend/scripts/airtable_sync_backfill.mjs (cron ***/15 * * * *, log data/airtable_sync.log) repêche toute vente restée airtable_id IS NULL et la re-pousse ; si une vente reste bloquée après 3 / 12 / 48 tentatives (~45 min / 3 h / 12 h), alerte dans les notifications (lib/notify.py). Option --resync pour re-pousser aussi les déjà synchronisées (mise à jour). Aucune vente ne peut être perdue silencieusement (coupure, Airtable indisponible, erreur ponctuelle). Config .env : MOA_AIRTABLE_TOKEN, AIRTABLE_BASE_ID, AIRTABLE_VENTAS_TABLE, AIRTABLE_CONTACTS_TABLE, AIRTABLE_AGENTS_TABLE, AIRTABLE_PROJECTS_TABLE. Testé de bout en bout : jeton validé en écriture (create+delete sur base Moa), backfill réel → vente d'Alexis (Michael Albert, Marena Torre 6 unité 907, 181 172 USD) créée dans « Unités vendues » (recqaM4oMMr4FKIBd), agent Alexis Moreira lié, contact client créé et lié, statut « Contrat en Attente », langue Ingles, et note « Projet Marena Torre 6 à créer/lier » (ce projet n'existe pas encore dans Projets Plan — il y a Marena torre 1/2/3). node --check OK sur server.js/airtable_sync.js/db.js/backfill, service redémarré**, cron installé. NB de sécurité : le jeton d'écriture est un secret dans backend/.env (non versionné). Correspondances non couvertes volontairement : champ « Affilié » (Ladislas/RP/MOA) laissé à Gary (pas d'info fiable côté CRM), pièces jointes/documents non poussées (restent dans le CRM).
  • - 2026-08-12 : WhatsApp <-> leads : rattachement STRICT (fiabilité 100%), re-rattachement rétroactif + auto, et conversation visible dans la fiche lead. Demande de Matt (vérif WhatsApp de Monica) : (1) elle reçoit bien, (2) ses leads sont-ils liés à la bonne conversation, (3) voit-elle la conversation depuis la fiche. Diagnostic : session open (numéro 595995606450), 614 messages reçus, MAIS ~500 non rattachés (matchLead ne s'exécutait qu'à la réception → un lead créé/édité APRÈS le message restait orphelin) et AUCUNE conversation visible dans la fiche (seulement un bouton wa.me externe). Correctifs backend (server.js) : (A-fiabilité) remplacement du match par « 8 derniers chiffres » (source de faux positifs) par samePhone(a,b) STRICT — égalité exacte des chiffres, OU égalité après suppression des zéros de tête, OU suffixe avec indicatif-pays de 1 à 3 chiffres ET partie nationale ≥ 9 chiffres (ex. 0995606450 ⇔ 595995606450). matchLead ne rattache plus QUE si exactement un lead correspond (sinon null → le numéro reste « à trier », jamais rattaché au mauvais lead ; les doublons de lead sont donc laissés de côté volontairement). (A-rétroactif) relinkLeadWa(lead) relie les messages orphelins dont le numéro correspond strictement à un lead ; appelé après création (POST /api/leads) et après édition si phone/whatsapp changent (PATCH). (B) nouvel endpoint GET /api/whatsapp/lead/:lid (auth + ensureLeadAccess) qui renvoie la conversation d'un lead rapprochée par NUMÉRO (donc visible même si lead_id n'a jamais été posé, y.c. doublons). Frontend : méthode whatsappLead() (api.js) + panneau « Conversation WhatsApp » (lecture seule, bulles in/out réutilisant le style de l'onglet WhatsApp) dans la fiche lead (app.js openLeadDetail, masqué s'il n'y a rien), i18n ld.waConv/ld.waConvSub ES/FR/EN. Cache-bust i18n.js/api.js/app.js ?v=20260812c. Backfill one-shot (backup DB data/moa-crm.db.bak-*-preWaRelink) : 14 conversations reliées (171 messages) pour Monica ; 2 doublons de lead laissés NON reliés (à fusionner : PHILIPPE ×2 num 33675925691 ; Dan Jones ×2 num 61434286653, l'un chez un autre agent). Monica passe de 114 à 286 messages liés (les 332 restants = contacts hors CRM, normal). Restart fait, node --check/node -c OK sur les 4 fichiers. Testé : /api/whatsapp/lead → Miguel Chamero 155 msg, PHILIPPE (doublon) 12 msg affichés quand même, lead d'un autre agent → 403 Accès refusé ; rendu réel vérifié Playwright (fiche Patricia Otero → panneau « Conversación WhatsApp 53 mensaje(s) » avec vraies bulles). Choix produit conservé : réception seule, un numéro inconnu n'est pas auto-converti en lead.
  • - 2026-08-12 : Backfill complet des leads Ladislas non importés (pré-avril) + audit anti-doublon. Suite à la demande de Matt (« y a-t-il d'autres leads récupérables ? je ne veux pas de doublon »). Audit d'abord : les 132 leads Ladislas parseables d'avant avril passés un par un dans la dédup exacte de l'endpoint (lower(email)= OU phone/whatsapp = normalizePhone) → 110 étaient déjà dans le CRM (arrivés via l'import Odoo, source « Immo/autre », assignés et travaillés : perdu/rdv_pris/intéressé), 1 doublon interne au lot, 21 réellement absents. Constat métier important : les leads Ladislas pré-avril n'ont pas été perdus (contredit l'estimation « ~55 perdus »), l'équipe les avait traités hors tag Ladislas. Import ensuite (script one-shot réutilisant sources/ladislas_moa_import.py, requête before:2026/04/01, POST vers /api/leads/ingest qui dédoublonne) : 19 leads créés « à assigner » (assignee NULL, source ladislas, stage sans_contact), 111 dédupliqués (0 doublon), 2 leads de test bloqués par une blocklist d'emails (nicktest@gmail.com, nckhad+matt@proton.me), 1 non parseable. État CRM après : 24 leads source ladislas, tous à assigner (5 avril + 19 backfill) ; bloc dashboard « Leads à assigner » = 25 (24 + ilinca oprescu WhatsApp). seen de l'état complété avec les ids traités ; last_lead_ts laissé au 14 avril (le moniteur de sécheresse reste honnête). Aucune modif de code cette fois (opération de données via l'endpoint existant).
  • - 2026-08-12 : Backfill des leads Ladislas d'avril + bloc « Leads à assigner » sur le dashboard admin. Demande de Matt : récupérer les leads Ladislas en attente depuis avril, les mettre « à assigner » dans le CRM, et les faire apparaître sur son dashboard admin. (1) Backfill ciblé avril 2026 (fenêtre choisie par Matt parmi 7/39/64/139 leads récupérables ; le flux Ladislas tournait bien de juillet 2025 au 14 avril 2026 puis s'est coupé net, aucun jamais importé) : script one-shot réutilisant sources/ladislas_moa_import.py (parse WPForms + post_ingest) avec une requête datée exacte after:2026/04/01. Résultat : 5 leads créés (Roger Baird, Katharina Beck, Nathan, Bryce J Bishop, Benjamin Herrmann), 2 dédupliqués (Karim Esgaib existait déjà, assigné à Alexis ; 2e Benjamin = doublon). Tous en source ladislas, tag Ladislas, stage sans_contact, assignee NULL (à assigner). Le seen de l'état a été restauré et last_lead_ts laissé au 14 avril (le moniteur de sécheresse reste honnête, l'import historique ne réarme pas l'horloge). (2) Dashboard admin (server.js GET /api/dashboard) : ajout de unassigned (COUNT leads assignee_id IS NULL AND stage NOT IN ('gagne','perdu')) + unassignedList (30 plus récents). Frontend (app.js renderDashboard) : nouvelle carte KPI « À assigner » (rouge si > 0) + panneau prioritaire « Leads à assigner » en haut (table cliquable nom/source/contact/date + bouton Assigner qui ouvre la fiche via openLeadDetail). i18n ES/FR/EN (dash.toAssign*, dash.assignCta, dash.andMore). Cache-bust i18n.js/app.js ?v=20260812b. Restart backend fait, node --check OK (server.js/app.js/i18n.js). Testé bout-en-bout (token admin forgé + Playwright) : /api/dashboard → unassigned=6, liste des 6 (5 Ladislas + ilinca oprescu WhatsApp) ; rendu visuel vérifié (KPI « À assigner : 6 », panneau avec les 5 leads Ladislas + boutons Assigner, source ladislas=5). Fenêtres plus larges (jan/oct/tout) non importées à la demande de Matt (les ~100 leads 2025 étaient sans doute déjà traités à l'époque).
  • - 2026-08-12 : Import automatique des leads Ladislas + surveillance (règle absolue Matt). Les leads Ladislas (The Wandering Investor) arrivent sur contact@moa-realestate.com, boîte relevée par le compte moarealestate.sa@gmail.com (accès AIOS existant, aucune nouvelle connexion). (1) Nouvelle porte d'entrée POST /api/leads/ingest (x-api-key MOA_INGEST_API_KEY dans backend/.env) : dédup par email/tél normalisés, lead créé « à assigner » (assignee NULL, stage sans_contact, source ladislas, tag Ladislas). (2) Poller sources/ladislas_moa_import.py (cron */15 min, --since 14) : lit Gmail en lecture seule (from:thewanderinginvestor.com to:contact@moa-realestate.com), parse WPForms (nom = sujet « from X », email = champ Email/Reply-To hors domaines plateforme fireside.bz + thewanderinginvestor, tél = corps), POST vers l'ingest, état local sources/ladislas_moa_state.json (ids vus + heartbeat). Notifie brièvement chaque nouveau lead ; sur erreur de relève : alerte immédiate (debounce 6 h). (3) Moniteur sources/ladislas_moa_monitor.py (cron quotidien 9h) : alerte « SERVICE COUPÉ » si aucun run OK depuis > 2h, « SÉCHERESSE » si aucun lead depuis > 30j (seuils via env MOA_LADISLAS_STALE_RUN_S / MOA_LADISLAS_DROUGHT_DAYS). Alertes → canal notifications (lib/notify.py). Volume constaté ~1-2 leads/mois (faible), d'où l'importance de la surveillance. Testé : ingest (create/dedup/401), parsing dry-run (12 leads / 160j OK), heartbeat, monitor OK, notification de confirmation livrée. Logs : sources/ladislas_moa.log. NB : les 12 leads historiques (> 14j) sont marqués « vus » (pas de backfill) ; l'automatisation ne prend que les nouveaux.
  • - 2026-08-12 : Sessions qui n'expirent plus : le JWT est désormais porté aussi par un cookie httpOnly + rafraîchissement glissant. Problème rapporté par Matt : ses agents se retrouvaient « plusieurs fois » sur l'écran de login (sessions expirées), ce qui lui coûtait du temps à chaque fois. Cause racine diagnostiquée : le token n'était stocké QUE dans le localStorage du navigateur. Le JWT durait pourtant 30 j, mais (1) iOS Safari / PWA efface le localStorage après 7 jours sans usage (Apple ITP, cap sur le script-writable storage) → l'agent était délogué alors que le JWT était encore valide ; (2) au boot, if (!API.token()) return showLogin() affichait le login sans même tenter de récupérer la session ; (3) aucun refresh glissant → éjection nette à 30 j même sur desktop. Correctifs. Backend (server.js) : le JWT est maintenant émis dans DEUX porteurs — le header Bearer (localStorage, inchangé) ET un cookie httpOnly moa_crm_session (Secure, SameSite=Lax, Max-Age 180 j), qui n'est PAS soumis au cap iOS 7 j → la session survit à l'effacement du localStorage. auth() accepte le token via Bearer OU cookie (repli), idem docAuth() (liens de documents nouvel onglet). Session glissante : maybeRefreshSession() re-signe un token neuf dès qu'il a plus de 1 j (repose le cookie + renvoie un header x-refresh-token), donc un utilisateur actif ne touche jamais le TTL. TTL de backstop porté 30 j → 180 j (signSession). Nouvel endpoint POST /api/logout qui efface le cookie côté serveur. Frontend : api.js envoie credentials:'same-origin' (le cookie part même sans Bearer), absorbe x-refresh-token pour garder le localStorage à jour, n'émet plus le toast « session expirée » quand aucun token n'était présent (évite le faux toast au 1er login), et expose logout(). app.js : le boot tente TOUJOURS /me (réussit via Bearer OU cookie → un localStorage effacé ne force plus la reconnexion) ; le bouton de déconnexion appelle /api/logout avant de vider le localStorage. Aucune migration DB, aucune dépendance ajoutée (lecture du cookie faite à la main, res.cookie natif Express 4). node --check OK (server.js, api.js, app.js), service redémarré. Testé (curl + token forgé via JWT_SECRET) : /api/me via cookie seul, sans Bearer → 200 (session récupérée) + Set-Cookie neuf + header x-refresh-token ; token frais (<1 j) → aucun refresh (0 header, pas de churn) ; POST /api/logout → cookie expiré (Expires 1970). Effet pour les agents : ils restent connectés en permanence, y compris sur iPhone en PWA ; plus de re-login récurrent.
  • - 2026-08-12 : Diagnostic connecteur MCP « ça charge et rien ne se passe » (symptôme rapporté par Matt). Vérification complète du serveur en direct sur l'URL publique : /.well-known/oauth-protected-resource(/mcp), /.well-known/oauth-authorization-server(/mcp), /.well-known/openid-configuration(/mcp) répondent tous 200 ; le flux entier rejoué à la main (DCR POST /register → GET /authorize 302 → /mcp/login 200 formulaire → prêt à échanger le code) fonctionne ; POST /mcp sans jeton renvoie 401 + WWW-Authenticate correct. Conclusion : le serveur CRM est irréprochable, le blocage est 100 % côté agent/claude.ai. Cause n°1 qui colle au symptôme « ça charge puis rien » = compte Claude gratuit (les connecteurs personnalisés sont réservés aux plans payants Pro/Max/Team) ; causes secondaires = popup de connexion bloquée par le navigateur, ou URL collée sans le /mcp final. Aucune modif de code. Fiche agent renforcée (workspace/moa-crm-mcp-connecter-claude.md) : gros avertissement « plan payant » en tête + section dépannage traitant pile ce symptôme. Décision produit (Matt, 2026-08-12) : on reste sur le connecteur MCP (gratuit pour Matt) plutôt que de basculer sur l'assistant IA intégré (étape 3, coûterait des tokens API).
  • - 2026-08-12 : Connecteur MCP read-only (OAuth 2.1 par agent) : chaque agent peut brancher SON propre Claude sur SON portefeuille. Étape 1 de la feature demandée par Matt (« les agents automatisent avec leur propre Claude »). Le CRM devient un serveur MCP distant ajoutable dans claude.ai → Connectors via l'URL https://crm-moa.panelbay.com/mcp. Nouveau module isolé backend/mcp/ (aucune modif du schéma CRM existant, tables MCP à part) : store.js (tables mcp_oauth_clients/codes/tokens + mcp_audit, jetons hashés sha256, codes single-use 10 min, access 1 h, refresh 30 j, rotation refresh), provider.js (implémente OAuthServerProvider du SDK MCP officiel @modelcontextprotocol/sdk v1.30 : DCR, PKCE S256, authorize→login→code→token, blob « pending » HMAC-signé avec JWT_SECRET), tools.js (6 outils lecture seule scopés par uid : whoami, list_my_leads, get_lead, lead_activities, pipeline_summary, agenda), index.js (wiring : mcpAuthRouter pour /.well-known/* + /authorize + /token + /register + /revoke, page de login /mcp/login réutilisant les comptes CRM, endpoint /mcp en Streamable HTTP avec sessions — enableJsonResponse, map session→agent, purge des sessions inactives 30 min, re-vérif token↔session à chaque requête). Monté par mountMcp(app) dans server.js avant le catch-all SPA (sinon index.html avalerait les routes) ; import ESM ajouté en tête. Sécurité : le scoping est en SQL côté serveur (admin=tout, sinon assignee_id=uid), donc impossible à contourner par prompt ; get_lead/lead_activities refusent un lead hors portefeuille ; chaque appel est journalisé (mcp_audit). Dépendances ajoutées : @modelcontextprotocol/sdk, zod. Testé de bout en bout (curl + PKCE, compte agent jetable) : DCR → authorize → login → code → token → initialize/session → tools/list (6 outils) → whoami OK → pipeline_summary renvoie total=1 (le lead de l'agent) et non 555 (cloisonnement prouvé) → get_lead sur un lead d'un autre agent = « Accès refusé » → refresh_token = nouveau access_token. Compte de test et données supprimés après coup. node --check OK sur les 5 fichiers, service redémarré. Métadonnées OAuth et 401+WWW-Authenticate vérifiés sur l'URL publique. Prérequis agent : plan Claude payant (connecteurs custom = plans payants). Reste à faire (étapes 2-3, non commencées) : outils d'écriture scopés + fonctions IA intégrées à quota par agent.
  • - 2026-08-11 : Déclaration de vente : fin du « Erreur serveur » opaque quand un document joint dépassait la limite (erreur multer non rattrapée). Symptôme rapporté par Matt : en envoyant une vente depuis le compte agent, le CRM affichait « Erreur serveur ». Cause diagnostiquée et reproduite : les erreurs multer (fichier trop volumineux, etc.) surviennent dans le middleware d'upload AVANT le handler POST /api/ventas, donc HORS de son try/catch ; comme server.js n'avait aucun middleware d'erreur Express, un fichier > limite renvoyait la page HTML Internal Server Error (500), que le front (api.js : data.error || 'Erreur serveur') affichait en « Erreur serveur ». Reproduit à l'identique : POST vente + pièce jointe > limite → 500 HTML. Ce cas se déclenchait facilement car une vente avec « réserva pagada » exige un comprobante, et les agents photographient/scannent leurs documents (photos haute résolution, PDF multi-pages). Correctifs (backend uniquement) : (1) backend/server.js : ajout d'un gestionnaire d'erreurs Express (dernier app.use((err,req,res,next)=>…), juste avant app.listen) qui rattrape les MulterError et renvoie un JSON clair au lieu du HTML : LIMIT_FILE_SIZE → 413 « Document trop volumineux : 25 Mo maximum par fichier… », LIMIT_FILE_COUNT → 413, autre → 400 ; toute autre exception non gérée → 500 JSON « Erreur serveur inattendue… » (le front affiche donc toujours un message actionnable, plus jamais l'opaque « Erreur serveur »). (2) backend/uploads.js : limite par fichier 15 → 25 Mo (couvre les photos/scans de téléphone courants ; files:20 inchangé). (3) backend/server.js (persistFiles, 415) : message de type non supporté rendu explicite (formats acceptés JPG/PNG/WEBP/PDF + astuce iPhone « Le plus compatible » / capture d'écran, car le HEIC iPhone n'est pas dans la liste des magic bytes acceptés). node --check OK, service redémarré. Testé (curl authentifié agent) : fichier > 25 Mio → 413 message clair (plus de HTML) ; type non supporté (.txt) → 415 message clair ; vente + PNG valide → 200 OK. Lignes/fichiers de test supprimés. NB : le HEIC iPhone reste refusé (415 avec guidage) ; support/conversion HEIC = amélioration future si besoin.
  • - 2026-08-25 : Nouvelle vue "Clients" (acheteurs gardés dans le CRM pour réoffre). Demande Matt : voir ceux qui ont déjà acheté avec lui, SANS les sortir du CRM, pour pouvoir leur reproposer autre chose (upsell/cross-sell). Modèle : un client = un lead au stage gagne ("Vente conclue"), déjà présent dans la table leads (jamais supprimé/archivé). La réoffre réutilise les champs existants next_action + next_action_date (pas de changement de schéma). FRONTEND uniquement : (1) nav data-view="clients" (icône ★, entre Prospects et Tâches) dans index.html ; (2) navLbl.clients='nav.clients' + showFor.clients=!co (visible admin + agents full, masqué au coordinateur Gary) dans applyGating/labels ; (3) gate renderView : clients derrière fullAgent() (coming-soon sinon) + dispatch renderClients(c) ; (4) renderClients = table des leads stage=gagne via API.leads('stage=gagne&assignee=…') ; admin a un filtre agent défaut = ses propres clients (state.me.id) + option "Tous les agents" ; colonnes Client (nom+pays) · Contact (whatsapp/tel/email) · Vendu le (stage_changed_at) · Agent · Réoffre (next_action+date, rouge si échue via .na-due) ; clic ligne → openLeadDetail (où l'on renseigne la réoffre) ; compteur + nb de réoffres dues. i18n nav.clients + clients.* (ES/FR/EN) dans i18n.js. CSS .na-set.na-due (rouge) dans styles.css. Restart fait (service actif, 8310 → 200), node --check app.js + i18n.js OK. Données live : 64 leads gagne (Matt 11, Sébastien 43, Raphaël 5, Facundo 3, Monica 2), 0 réoffre encore renseignée (concept neuf). NB : "ce qu'il a acheté" en détail passe par la fiche (la liste garde des colonnes légères) ; brancher plus tard le lien client→vente (table ventas) si besoin d'afficher l'unité achetée directement dans la liste.
  • - 2026-08-11 : Formulaire de vente : message d'erreur nommant les champs manquants + correction du bouton Submit qui ne soumettait pas au clic. Demande de Matt : quand l'agent ne remplit pas tous les champs obligatoires, aucun message n'apparaissait. Deux causes trouvées et corrigées, purement frontend, frontend/js/app.js (fn openVentaForm) + i18n.js + styles.css. (1) Bug racine : le bouton "Envoyer" est dans le footer du modal (.modal-foot), hors du <form> ; un <button type=submit> hors formulaire ne déclenche RIEN au clic (seule la touche Entrée dans un champ soumettait). Corrigé en donnant au form un id unique (vform-<submit_token>) et en posant form=<id> sur le bouton (rattachement natif HTML). Désormais le clic soumet réellement → la validation s'exécute. (2) Message explicite : avant, highlight() faisait défiler vers le 1er champ en erreur, ce qui poussait le message générique (placé en haut du scroll) hors écran → invisible. Nouveau comportement : la validation liste nommément tous les champs manquants (libellés i18n dédupliqués via une map labels{} remplie par field/textarea/select + entrées manuelles pour promotor/proyecto/comprobante_reserva) dans un bandeau .vf-errmsg (titre vf.errMissingTitle "{count} champ(s) obligatoire(s) manquant(s) :" ES/FR/EN + <ul class="vf-missing"> en 2 colonnes), on scrolle vers le message (plus vers le champ), les champs restent surlignés en rouge. Idem branche erreur serveur (ex.data.fields). Cache-bust styles.css/i18n.js/app.js ?v=20260811d, SW moa-crm-v23-20260811d. Front statique, pas de restart. Testé Playwright bout-en-bout (compte agent) : ouverture du form → clic réel sur le bouton "Submit" du footer → bandeau rouge "19 required field(s) missing:" listant les 19 champs + champs surlignés (capture validée). node --check OK.
  • - 2026-08-11 : Formulaire de vente : Marena Torre 1 à 8 sélectionnables + email de vente explicitement adressé à Gary. (1) Marena 1→8 : scripts/seed_approved_projects.mjs (tableau MANUAL) étendu de ['Marena Torre 2','Marena Torre 3'] aux 8 tours Marena Torre 1..8. Re-seed (node scripts/seed_approved_projects.mjs, idempotent, +6 nouvelles) → 155 projets actifs / 20 promoteurs. L'agent choisit "Marena" dans le sélecteur promoteur→projet et voit les 8 tours. Pas de restart (l'endpoint GET /api/approved-projects lit la DB en direct) ; vérifié par appel authentifié : Marena renvoie Torre 1 à 8. (2) Email vente → Gary : à chaque soumission de vente (POST /api/ventas), l'email partait déjà vers la boîte MOA moarealestate.sa@gmail.com avec le récap complet (client, WhatsApp, projet, promoteur, unité, typologie, prix, réserve, plan de paiement, asesor, agent, statut, documents). Rendu explicitement adressé à Gary : titre Nueva unidad vendida para procesar + ligne "Hola Gary, …" (ventaEmailHtml dans server.js), sujet Nueva unidad vendida para procesar (Gary) - <projet> - Unidad <n> - <client>, et Gary (role coordinateur) mis en copie directe (cc dérivé de la DB WHERE role='coordinateur', dédupliqué de la boîte MOA ; sendMail({to, cc, ...}), le pont send_mail.py gère déjà cc). Aucun Airtable pour cette partie (à faire plus tard quand Matt fournira un token en écriture). Restart fait (backend), node --check OK. Testé : email de test réellement envoyé (Gmail id retourné, boîte MOA + cc Gary). Sale form draft localStorage (submit_token) inchangé.
  • - 2026-08-11 : Fiche lead : brouillon local anti-perte de saisie (coupure réseau / rechargement / onglet fermé). À la demande de Matt : quand on édite une fiche client et que la fiche se ferme (internet instable, page rechargée), on ne perd plus ce qu'on vient de saisir. Purement frontend, frontend/js/app.js (fn openLeadDetail) + i18n.js + styles.css. Mécanique : (1) helpers leadDraftKey/readLeadDraft/writeLeadDraft/clearLeadDraft (localStorage, clé moa_crm_leaddraft_<idLead>, try/catch pour le mode privé). (2) Auto-sauvegarde continue : collectDraft() capture la valeur de tous les champs f (email, tél, budgets, notes, selects…) + l'agent assigné ; persistDraft (debounce 400 ms) écrit dans localStorage sur tout événement input/change du modal (délégation posée sur modalNode). Rien n'est envoyé au serveur ici, c'est un filet local. (3) Restauration : à l'ouverture d'une fiche, si un brouillon existe ET diffère des valeurs serveur, on ré-injecte les valeurs dans les champs et on affiche un bandeau .draft-banner "⚠ Modifications non enregistrées récupérées" avec un bouton Ignorer (draft.recovered/draft.discard, traduits ES/FR/EN) qui purge le brouillon et rouvre la fiche propre depuis le serveur. Si le brouillon est identique au serveur, il est purgé silencieusement. (4) Nettoyage : save() appelle clearLeadDraft(l.id) après un updateLead réussi (un enregistrement OK efface le brouillon). Le stage est déjà persisté serveur à chaque clic (setStage), donc hors brouillon. Cache-bust styles.css/i18n.js/app.js ?v=20260811b, SW moa-crm-v21-20260811b. Front statique, pas de restart. Testé Playwright bout-en-bout : saisie email+notes → brouillon écrit en localStorage ; reload complet (simule la fermeture) → réouverture de la même fiche restaure email+notes + bandeau affiché ; bouton Ignorer → brouillon purgé, champs revenus aux valeurs serveur, bandeau disparu. node --check OK, données de test purgées.
  • - 2026-08-07 : Alexis Moreira passé en accès complet + import de ses leads Odoo. À la demande de Matt. (1) Accès : ajout de alexismoreira@moa-realestate.com à FULL_AGENT_EMAILS (nouvelle ligne dans backend/.env, avec Monica conservée) → il débloque le pipeline complet, la gestion des leads et la recherche globale (comme Monica), toujours scopé à son propre book (pas admin, il ne voit pas les leads des autres). Ajout aussi à WA_ALLOWED_EMAILS (bouton WhatsApp disponible ; il devra scanner son QR pour connecter son numéro, rien n'est connecté automatiquement). (2) Leads : ajout d'Alexis (Odoo user 19 → CRM ZGltxYZHBQIh75eS) à la table PEOPLE de scripts/import_odoo_leads.py, puis --apply → 22 leads importés (11 contacté, 8 sans contact, 2 intéressé, 1 rdv pris), tagués intake_source='odoo_import', idempotents (les autres agents déjà importés = 0 réinsertion). Restart fait (service actif, port 8310 HTTP 200). Vérifié : SELECT COUNT(*) leads WHERE assignee_id='ZGltxYZHBQIh75eS' = 22.
  • - 2026-08-07 : Normalisation automatique des numéros de téléphone (filet de sécurité à l'écriture). Nouveau normalizePhone() exporté par db.js : nettoie ponctuation/espaces, retire les marques Unicode invisibles (LTR/RTL, fréquentes dans les numéros copiés depuis WhatsApp/iOS), convertit un préfixe international (+ ou 00) en +, garde les chiffres, sans jamais deviner l'indicatif pays (un numéro local reste local). Appliqué à TOUS les points d'écriture d'un numéro : POST /api/leads (création), PATCH /api/leads/:lid (modif), POST /api/leads/intake (coordinateur). Ainsi quoi que tape l'agent, le numéro est reformaté avant stockage → le rattachement WhatsApp (match sur les 8 derniers chiffres) ne rate plus à cause d'un formatage bancal (ex. '+1 (347) 393-7900 → +13473937900). Même logique dupliquée en Python dans scripts/import_odoo_leads.py (normalize_phone()) pour les futurs imports Odoo. Backfill de l'existant : 227 leads normalisés (backup DB data/moa-crm.db.bak-*-prePhoneNorm). ⚠️ Incident pendant le backfill : une 1re version de la regex ne gérait pas les marques invisibles et a retiré le + de 8 numéros internationaux ; réparé en re-dérivant la valeur d'origine depuis Odoo (source de vérité) puis re-normalisant. État final vérifié : 0 numéro avec espace/parenthèse/marque, les + internationaux préservés. node --check OK, service redémarré. NB : matchLead (rattachement) matche déjà sur les chiffres seuls, mais le stockage propre fiabilise l'affichage et le click-to-call.
  • - 2026-08-07 : Leads saisis à la main tagués intake_source='manual' (pour les stats du cockpit). server.js POST /api/leads pose désormais intake_source='manual' sur toute création via l'interface (Gary, agent, admin), ajouté aux cols de l'INSERT. Objectif : le cockpit MOA distingue la saisie humaine des imports automatiques (odoo_import, airtable_vente) et la compte dans ses statistiques de leads (module lecture seule moa_crm_leads.py côté cockpit, aucune écriture sur cette base). Restart fait, node --check OK. Vérifié : lead créé via l'API → intake_source='manual' en base → compté par le cockpit (/api/moa leads_manuels, /api/series métrique "Saisis CRM (manuel)"). Détails côté cockpit : voir son CHANGELOG (même date).
  • - 2026-08-07 : Vue "Promoteurs" (ADMIN uniquement) déplacée du cockpit vers le CRM MOA. Annuaire des promoteurs (table Airtable "Promoteurs" base Moa appSVJQULMJNXy9Hl, lecture seule) + statut de scrap des unités + pistes marché. BACKEND : scripts/promoteurs.py autonome (lit Airtable via AIRTABLE_API_KEY urllib+pagination depuis /root/aios/.env, et moa-catalog/catalog.db pour le scrap ; rapprochement Airtable↔catalogue par promoteurs.airtable_id puis nom normalisé / premier mot ; classe absent/no_units/warn/ok avec soucis détectés, STALE_DAYS=45). Route GET /api/promoteurs (auth+requireAdmin, 403 pour les non-admins) qui appelle le script via runPy (import bridge.js). FRONTEND : entrée nav promoteurs (visible admin seulement, gating dans applyGating+navigate, dispatch renderView gardé par isAdmin()), renderPromoteurs (KPIs Signés/À signer/Inactifs/Pistes + KPI rouge "Alertes scrap", tables signés (7 col. avec colonne "Scrap unités") / à-signer / pistes marché / inactifs ; badge de scrap ✓ À jour / ⚠ Partiel / ✗ Pas de prix / ✗ Jamais scrapé + compteur + date + soucis), méthode promoteurs() dans api.js, CSS .promo-* + .scrap-*. Cache-bust ?v=20260807d, SW moa-crm-v15-20260807. Restart fait. Testé : admin → 14 signés / 9 à signer / 1 inactif / 8 pistes / 10 alertes ; agent (Monica) → 403 ; node --check OK. Le même tableau a été retiré du cockpit (voir son CHANGELOG). Constat métier : 6 promoteurs signés absents du catalogue (Avanza, Cima, Insignia, Marena, Nativus, Perseverencia), Altea sans liste de prix, Civis/Petra/Uno Tres partiels.
  • - 2026-08-07 : Module WhatsApp V3 = Assistant IA sur le fil (résumé + scoring + réponse suggérée, ancrée MOA Academy). Sur chaque conversation de l'inbox, un bouton "✨ Assistant IA" produit : (1) un résumé FR de la conversation, (2) un score du lead A-D + justification, (3) une RÉPONSE SUGGÉRÉE = le prochain message à envoyer, dans la langue du prospect, calquée sur la méthode MOA Academy (surtout pour les premiers contacts). Réception seule : la suggestion se copie dans le presse-papier (bouton Copier), le CRM n'envoie jamais. BACKEND : scripts/ai_assist.py (OpenAI gpt-4o via urllib, clé OPENAI_API_KEY>OPENAI_STT_API_KEY depuis /root/aios/.env, response_format=json_object) : injecte comme grounding les sections utiles de /root/workspace/MOA-Academy/00-base-connaissances-agents.md (titres "## 1/4/5/6" = vue d'ensemble + scripts de contact + relance + culture WhatsApp, slice dynamique par heading, cap 60k car), + infos lead + transcription de la conversation + prénom de l'agent (signe le message, jamais de placeholder "[tu nombre]"). Règles imposées : ton humain court, saluer/créer du lien avant de pitcher, ne pas tout déballer, pousser vers un appel/visio, ne jamais inventer prix/projet/dispo. Module ai.js (wrappe runPy('ai_assist.py') via bridge.js). Route POST /api/whatsapp/thread/ai (auth + requireWa) : charge le fil + le lead lié, appelle l'IA, renvoie {summary,score,score_reason,suggestion,suggestion_reason}. FRONTEND : bouton dans l'en-tête du fil (waAiAssist) → modale (résumé, badge score coloré A=vert→D=rouge, suggestion en bulle verte + bouton Copier avec fallback execCommand). Cache-bust ?v=20260807c, SW moa-crm-v14-20260807. Restart fait. Testé bout-en-bout : scénario premier contact (lead demande le prix) → l'IA déflecte le prix, pose une question de qualification et propose un appel de 5 min, en espagnol, signé du prénom de l'agent (fidèle au SCRIPT AGENT MOA) ; JSON valide ; données de test purgées. Non fait volontairement (choix Matt) : "biens qui matche" reporté. L'envoi reste hors scope (API officielle Meta requise).
  • - 2026-08-07 : Module WhatsApp V2 = vraie INBOX (réception seule, aucun envoi). Décision produit de Matt : le CRM devient une boîte de réception WhatsApp, mais on n'envoie AUCUN message depuis le CRM (anti-ban : usage 100% passif). Retiré : la route POST /api/whatsapp/send et l'encart d'envoi de test de la V1. Ajouté : (1) wa_evolution.js → setWebhook(name,url) (abonne l'instance à MESSAGES_UPSERT) + parseInbound(data) (extrait numéro/texte/fromMe/pushName/ts, ignore groupes/statuts, gère image/vidéo/audio/doc via libellé). (2) Table wa_messages (id, user_id, instance, wa_number, push_name, lead_id nullable, direction in/out, body, wa_message_id, ts, read) + index thread + unique (user_id,wa_message_id) anti-doublon. (3) server.js : le webhook est posé automatiquement à la connexion (route connect → setWebhook vers WA_PUBLIC_BASE/api/whatsapp/webhook/<instance>?token=WA_WEBHOOK_SECRET). Endpoint public POST /api/whatsapp/webhook/:instance (protégé par secret en query, acquitte puis traite) : mappe l'instance→user via wa_sessions, matchLead() rapproche le numéro d'un lead existant par suffixe 8 chiffres (normalisation JS, tolère les espaces/préfixes), insère le message, et journalise un activities type whatsapp_in sur le lead si connu. ANTI-SPAM : un numéro inconnu n'est JAMAIS auto-converti en lead — il reste dans un bac "à trier", l'agent le convertit à la main via POST /api/whatsapp/thread/create-lead. Endpoints de lecture : GET /api/whatsapp/inbox (conversations groupées par numéro, non-lus, dernier message, lead lié), GET /api/whatsapp/thread?number= (fil complet + marque lu). Tout est scopé par utilisateur (req.user.id = propriétaire de l'instance). (4) Frontend : la vue "WhatsApp" devient une inbox (renderInbox/openThread) : liste des conversations (badge non-lus, tag "lead"/"à trier"), fil de messages en bulles in/out lecture seule (pas de zone de réponse), bouton "Voir la fiche" (lead connu) ou "Créer un lead" (inconnu), rafraîchissement doux (liste 12 s, fil 8 s). Retrait de l'encart d'envoi. (5) .env : WA_PUBLIC_BASE + WA_WEBHOOK_SECRET. Restart fait. Cache-bust styles.css/app.js/api.js ?v=20260807b, SW moa-crm-v13-20260807. Testé bout-en-bout (webhook simulé) : secret validé + mauvais secret = 403 ; numéro connu → rattaché au lead Florent + activité whatsapp_in ; numéro inconnu → bac "à trier" (lead_id null, 0 lead auto-créé) ; inbox + thread + non-lus OK ; données de test purgées. V3 prévue : alertes "bien qui matche" (catalogue × critères leads), IA sur conversation (résumé/sentiment/réponse suggérée), prise de RDV de visite + rappels. Reste hors scope volontairement : l'envoi (nécessiterait l'API officielle Meta sur un numéro dédié).
  • - 2026-08-07 : Module WhatsApp par agent (V1, pilote réservé à Monica) via Evolution API. Chaque agent autorisé peut connecter son propre WhatsApp au CRM (une instance Evolution par utilisateur, nom moacrm_<userId>) et envoyer des messages depuis le CRM. BACKEND : nouveau module backend/wa_evolution.js (parle à Evolution API auto-hébergée sur 127.0.0.1:8080, clé EVOLUTION_API_KEY côté serveur uniquement ; fns instanceName/ensureInstance/connect/state/fetchInstance/sendText/logout). Table wa_sessions (user_id PK, instance, status, number) dans db.js. Routes dans server.js : GET /api/whatsapp/status (état + numéro appairé), POST /api/whatsapp/connect (crée l'instance + renvoie le QR base64), POST /api/whatsapp/logout, POST /api/whatsapp/send ({number|leadId, text} ; un envoi vers un lead est journalisé en activities type whatsapp). Feature-flag : WA_ALLOWED_EMAILS dans backend/.env (défaut monicaayala@moa-realestate.com) + les admins ; helper waAllowed(), middleware requireWa (403 sinon), et wa_enabled exposé dans publicUser (pilote le nav côté front). FRONTEND : entrée nav whatsapp (masquée sauf wa_enabled), vue renderWhatsapp/paintWa (bouton "Connecter mon WhatsApp" → QR affiché in-app + polling /status toutes les 3 s jusqu'à open ; une fois connecté : numéro + encart d'envoi de test + "Déconnecter"), méthodes whatsapp* dans api.js, CSS .wa-*. .env du CRM complété (EVOLUTION_API_URL, EVOLUTION_API_KEY, WA_ALLOWED_EMAILS). Restart requis (nouveau module + env, fait). Testé : node --check sur tous les fichiers ; Monica (agent) → wa_enabled:true, connect renvoie un QR (13 Ko base64), instance passe en connecting ; Abel (agent non listé) → wa_enabled:false, /status enabled:false, /connect = 403. Caveat : protocole WhatsApp Web non-officiel (usage conversationnel 1-à-1, pas de masse). V2 prévue : bouton d'envoi WhatsApp directement sur la fiche lead + réception (webhook Evolution → fil de conversation par lead).
  • - 2026-08-06 : Nouveau logo visuel (emblème skyline) à la place du monogramme lettres. Remplacement de l'ancienne icône "MOA/CRM" en lettres par un vrai emblème vectoriel : skyline de 3 tours (immobilier + croissance/pipeline) avec fenêtres en négatif, soleil doré en fond et balise dorée sur la tour centrale, dans la charte MOA (forest #0A2A21 / cream #F6F1E7 / gold #C9A24B, #E4C987). Source unique frontend/icons/build_logo.py (cairosvg) qui génère : logo-rounded.svg (badge fond vert arrondi), logo-mark.svg (marque transparente crème/doré pour fond sombre), et rasterise favicon-32, icon-192, icon-512, apple-touch-icon (180), icon-maskable-512 (fond full-bleed, contenu dans la safe-zone). HTML : badge vert arrondi sur l'écran de login (fond clair), marque transparente crème/doré dans la sidebar (fond vert), wordmark "MOA CRM" conservé à côté. CSS .brand-badge (login 48px, sidebar 32px). Cache-bust favicon/apple-touch ?v=20260806, styles.css?v=20260806k, SW moa-crm-v11-20260806. Régénérer les icônes : python3 icons/build_logo.py. Testé Playwright : emblème rendu sur login + sidebar, favicon lisible à 32px. Front statique (pas de restart).
  • - 2026-08-06 : Purge des leads de démo + import des acheteurs Airtable "Unités vendues" (Sébastien +22, Matthieu 16). (1) Clean : suppression des 61 leads de démo/seed (identifiés par intake_source IS NULL OR ='coordinateur_manual') et de leurs lignes filles (235 activities, 58 lead_properties). Les leads réels (odoo_import, airtable_vente) ne sont jamais touchés. Garde-fou : abort si une ventas référence un lead à supprimer (0 ici). (2) Import acheteurs depuis la base Airtable "Moa" (appSVJQULMJNXy9Hl), table Unités vendues (tblfIJdn0MB5dHDJ4), filtrée par le champ lien Agent = vendeur (Sébastien rec0969rRio1PQGJc, Matthieu rech4NHWRlRyZrqcg). Résolution des liens Nom client → table Contacts (tbla4ms3q9vSLj8Y5) pour nom/téléphone/email ; dédoublonnage par client (un acheteur peut avoir plusieurs unités) et par nom normalisé. Stade = gagne si au moins une unité non "Perdu", sinon perdu. expected_revenue = somme des "Prix payé", unités + affilié consignés dans les notes, source='MOA', intake_source='airtable_vente'. Résultat : Sébastien +22 (→43 au total avec ses 21 Odoo), Matthieu 16 (repartait de 0). Le champ Airtable "Affilié" (Resident Paraguay/MOA/Ladislas) est la marque source, PAS un vendeur : l'affiliation à une personne = le champ Agent. Scripts réutilisables : scripts/at_build_buyers.py (extraction → scripts/_buyers.json), scripts/clean_and_import_buyers.py (--dry/--apply). Backup data/moa-crm.db.bak-*-preclean. Intégrité OK, 0 orphelin, 0 lead test restant. Vérifié Playwright (board admin = données réelles, aucun nom de démo). État CRM : 148 leads (110 odoo_import + 38 airtable_vente).
  • - 2026-08-06 : Import des leads Odoo réels de Mathis (89) et Sébastien (21). Nouveau script idempotent scripts/import_odoo_leads.py (Odoo XML-RPC via lib/odoo_client.py → sqlite). Récupère toutes les crm.lead par vendeur (user_id), mappe les stades Odoo → pipeline CRM (New Lead/A trier→sans_contact, Contacted→contacte, Qualified→interesse, Appointment→rdv_pris, Offer/Negociation→proposition, Won→gagne, Lost/Not Qualified→perdu), préserve create_date, dénormalise (name via contact_name>partner_name>name, phone, email, description strip-HTML, city, country, expected_revenue), tague intake_source='odoo_import' + odoo_lead_id (dédup). Idempotent : saute les odoo_lead_id déjà présents. Mathis (Odoo uid 7 → CRM syIRGtK64eyWkSAn) : 89 leads (70 perdu, 17 interesse, 2 sans_contact). Sébastien (uid 11 → cZE1hBEFXeQjHjV7) : 21 (19 gagne, 1 interesse, 1 sans_contact). Backup DB data/moa-crm.db.bak-20260806-140339-preimport. Intégrité OK. Constat : avant import, Mathis et Sébastien avaient 0 lead assigné ; les 61 leads pré-existants sont des données de démo/seed (50 non assignés + 11 Gary + 2 intakes de test), aucun rattaché à eux. Pas de restart (données lues à la volée). Ré-exécuter pour d'autres agents : éditer le dict PEOPLE.
  • - 2026-08-06 : Pipeline : libellé "Contacté" raccourci + en-têtes de colonnes sur une seule ligne. (1) stage.contacte renommé de "Contacté (sans réponse)" en "Contacté" (ES "Contactado", EN "Contacted") dans i18n.js. (2) Les titres de colonnes ne passent plus à la ligne : .col-head .t en white-space:nowrap;flex:0 0 auto et .col-head en flex-wrap:wrap (le montant .v reste sous le titre). Vérifié : les 9 libellés (dont le plus long "Proposition envoyée") tiennent sur 1 ligne sans débordement dans la colonne de 270px. Cache-bust i18n.js?v=20260806h, styles.css?v=20260806j, SW moa-crm-v10-20260806. Front statique (pas de restart). Testé Playwright : 9 en-têtes = 1 ligne chacun, 0 overflow.
  • - 2026-08-06 : Fiche lead : bouton WhatsApp mis en avant. L'ancien petit lien texte .wa-link ("💬 WhatsApp") de l'en-tête de la fiche est remplacé par un vrai bouton pill vert WhatsApp (.wa-btn, fond #25D366, texte blanc gras, logo WhatsApp officiel en SVG inline, ombre + hover). Plus gros et impossible à manquer. Même comportement (https://wa.me/<num> nouvel onglet, numéro dérivé de whatsapp sinon phone). app.js (fn openLeadDetail, en-tête modal) + styles.css (.wa-btn). Cache-bust styles.css?v=20260806i, app.js?v=20260806j, SW moa-crm-v9-20260806. Front statique (pas de restart). Testé Playwright : bouton rendu 132×38 px, logo + libellé, 0 erreur console.
  • - 2026-08-05 : Carte : style lisible, vrai filtre "sans dispo" (pins rouges) + fond de carte fantôme corrigé + vue par défaut sur Asunción. (1) Style : passage de mapbox/light-v11 (gris, plat) à mapbox/streets-v12 (coloré, lisible). (2) Bug "1 dispo" fantôme corrigé côté données : dans catalog.js, les agrégats n_libres comptaient la ligne NULL du LEFT JOIN pour les projets à 0 unité (u.statut IS NULL → +1), d'où des projets "1 dispo" qui n'ont en réalité aucune unité (l'immeuble lui-même). Ajout du garde u.id IS NOT NULL AND (…) dans projectsGeo(), promoters() et projects(). Effet : 78 projets à 0 unité repassent à 0 dispo (55 projets ont une vraie dispo), les vrais "1 libre" (Porta 3 1/70, First Living 1/1) sont préservés. (3) Filtre carte refait : les projets sans dispo sont masqués par défaut ; la case "Afficher les projets sans dispo (en rouge)" les affiche en marqueurs rouges (#e5484d) sur une source dédiée non clusterisée (projects-empty), pendant que les projets avec dispo restent en vert clusterisés (projects). Popup adapté ("Aucune unité disponible"). Pastille de légende "Sans dispo" passée au rouge. (4) Vue par défaut centrée/zoomée sur Asunción (ASU = {center:[-57.56,-25.30], zoom:12}) : suppression du fit-bounds automatique (qui dézoomait pour englober CDE/Encarnación) + bouton "⌖ Recentrer sur Asunción" dans la légende. i18n : map.recenter, map.noDispo, map.showEmpty (mention "en rouge"). Cache-bust ?v=20260805f (js + styles.css). Restart moa-crm effectué (catalog.js). Testé Playwright : carte colorée centrée Asunción, marqueurs verts par défaut, toggle → pins rouges répartis, 0 erreur console.
  • - 2026-08-05 : Ville canonique par géoloc + filtres POI sur la carte + fix V Tower del Lago. (1) Localité depuis la géoloc : chaque entrée de backend/data/project_geo.json porte désormais un champ city (déduit de l'adresse/coords de la recherche). catalog.js resolveCity() = geoloc city > zona (colonne catalog.db souvent vide). cities() et unitsSearch() (filtres q, city, excludeCities déplacés en JS post-SQL) utilisent cette ville. Effet : une recherche "Luque" retourne les 7 projets avec dispo (~361 unités) au lieu d'un seul (avant, seul comptait zona='Luque', quasi jamais rempli). projectsGeo() et le popup carte exposent city. (2) V Tower del Lago corrigé en Ciudad del Este (-25.51,-54.61) → carte 141/141 localisés. (3) Filtres Points d'intérêt sur la carte : 10 catégories togglables (centres commerciaux, supermarchés, écoles, universités, santé, parcs, banques, salles de sport, ambassades, aéroport). Données OSM récupérées via moa-catalog/scripts/fetch_pois.py (Overpass, régions Gran Asunción + Ciudad del Este + Encarnación ; catégories denses = named-only) → backend/data/pois.json (4457 POI). Route GET /api/catalog/pois, catalog.js pois(). Frontend : panneau repliable "📍 Points d'intérêt", chargement paresseux (fetch au 1er cochage), 1 calque circle Mapbox par catégorie (couleur dédiée, visibilité togglée), popup au clic, état persisté (mapPois dans moa_crm_catprefs). Pour rafraîchir les POI : relancer fetch_pois.py. Cache-bust ?v=20260805e. Testé Playwright : recherche Luque=7 projets, carte 141/141, panneau POI 10 catégories, toggle "centres commerciaux" OK, 0 erreur console. Restart moa-crm effectué.
  • - 2026-08-05 : Vue carte des projets (Recherche unités), camouflable. Nouvelle carte Mapbox de TOUS les projets du catalogue, accessible via un bouton "🗺️ Carte" dans la barre de Recherche unités (masquée par défaut, état persisté par agent dans moa_crm_catprefs). (1) Géoloc : 141 projets géolocalisés par recherche web (6 agents), stockés dans backend/data/project_geo.json (clé stable norm(promoteur)|norm(projet), HORS catalog.db que les crons purgent). Résultat : 140 localisés (34 adresse exacte, 96 quartier, 10 ville) + 1 non localisable (V Tower del Lago, nom ambigu entre 2 promoteurs). (2) Backend : catalog.js projectsGeo() joint geo + compteurs d'unités + fourchette de prix USD ; route GET /api/catalog/map (renvoie aussi le MAPBOX_PUBLIC_TOKEN du .env). (3) CSP : helmet étendu pour autoriser le CDN Mapbox (api.mapbox.com, *.tiles.mapbox.com, events.mapbox.com, blob: pour le worker). (4) Frontend : Mapbox GL JS lazy-loadé au 1er affichage ; clustering, marqueurs colorés par dispo (vert = unités libres, gris = aucune), popup projet (promoteur, zone, dispo, prix, "Voir les unités" → ouvre le projet), fit-bounds, légende, toggle "Afficher les projets sans dispo", et une liste repliable "Projets non localisés" (transparence). Token Mapbox public pk.* copié dans backend/.env (MAPBOX_PUBLIC_TOKEN). Cache-bust ?v=20260805d. Testé : node (projectsGeo 141/140), navigateur Playwright (login, toggle carte, 140 pins + clusters Asunción/CDE, popup, masquer/afficher persisté, 0 erreur console). Restart moa-crm effectué. Pour re-géolocaliser un projet : éditer backend/data/project_geo.json.
  • - 2026-08-05 : Onboarding par lien d'invitation unique (remplace le mot de passe temporaire commun). Chaque agent reçoit un lien unique crm-moa.panelbay.com/?invite=<token> : il l'ouvre, choisit sa langue et crée son mot de passe (login = son email @moa-realestate.com). Le lien est à usage unique et expire (30 j). DB (db.js) : colonnes users.invite_token + invite_created_at (+ index unique partiel). Backend (server.js) : POST /api/admin/invites (admin ; {userId} ou {pending:true} ; génère le token ET neutralise le mot de passe courant pour que le lien soit le seul accès ; ne touche jamais matt@moarealestate.com), GET /api/invite/:token (public, infos d'affichage), POST /api/invite/:token (public, crée le mot de passe + langue, usage unique, connecte直). Frontend : écran d'invitation (renderInviteScreen dans app.js, détecté via ?invite= au boot, marque MOA, choix de langue live ES/FR/EN, mot de passe + confirmation, i18n invite.*, CSS .invite-*). Les popups d'onboarding historiques (changement de mot de passe forcé + picker langue) ne se déclenchent plus (les comptes invités ont must_change_password=0 + langue après acceptation, et un mot de passe temporaire neutralisé). Script scripts/gen_invites.mjs (génère les liens des comptes en attente, écrit le récap /root/workspace/moa-crm-invitations.md). Cache-bust ?v=20260805c. Testé : GET/POST invite (création + usage unique 404 au rejeu), login avec le nouveau mot de passe, écran vérifié au navigateur (0 erreur console). 10 liens générés (Seb + Gary + 8 agents) ; 3 agents déjà onboardés (Alexis, Mathis, Monica) et Matt conservent leur mot de passe.
  • - 2026-08-05 : Module Ventes + Coordinateur, jalons 3 à 8 (backend + frontend complet). BACKEND (server.js + nouveaux modules bridge.js, mailer.js, odoo.js, whatsapp.js, uploads.js, scripts Python scripts/send_mail.py/odoo_create_lead.py/extract_whatsapp.py) : (1) POST /api/ventas (multipart) : validation serveur (requis + conditionnels), coercition numérique, idempotence via submit_hash (index UNIQUE), statut auto reservado/en_espera_reserva, upload documents validés par magic bytes, rattachement lead d'origine, email au coordinateur (moarealestate.sa@gmail.com, pont Gmail lib/google_workspace), event d'audit create+email_sent/failed/skipped. (2) GET /api/ventas (agent = les siennes, coordinateur/admin = toutes), GET /api/ventas/:id, PATCH /api/ventas/:id/status (coordinateur/admin uniquement, ne touche QUE le statut, event status from→to+IP), POST /api/ventas/:id/documents, GET /api/documents/:id (gardé, auth Bearer OU ?t=, jamais d'URL publique devinable), GET /api/ventas/lead-search. (3) Intake coordinateur : POST /api/leads/extract (vision OpenAI, ne fait que pré-remplir), POST /api/leads/intake (crée le lead dans Odoo via odoo.js = couche d'abstraction unique, puis en local "à assigner" assignee_id=NULL, échec Odoo NON silencieux + capture attachée), GET /api/leads/queue, GET /api/coordinateur/overview (untreated / en espera / relances J+3 docs et J+7 promoteur / leads à assigner). (4) Interrupteur MOA_CRM_NOTIFY (.env, 1=on). Vocabulaire canonique des selects (valeurs stockées indépendantes de la langue) + libellés ES pour l'email. FRONTEND (app.js, i18n.js, api.js, index.html, styles.css) : vue Ventes (agent) avec bouton "Validar una venta" + liste de ses ventes ; formulaire de vente v2 plein écran, 7 sections, multilingue via l'i18n existant (ES/FR/EN), sélecteur promoteur→projet avec recherche depuis le catalogue moa-catalog, validation conditionnelle (comprobante obligatoire si réservation payée), upload documents, brouillon localStorage, agent auto-rempli non modifiable, idempotence (submit_token) ; vue Coordination (coordinateur/admin) avec tuiles d'aperçu, liste des ventes + avancement de statut, file des leads à assigner, détail vente (documents + timeline d'audit) ; modale Nuevo lead (manuel + extraction WhatsApp, numéro mis en évidence pour relecture, mention "rien sans validation humaine"). Cache-bust ?v=20260805b. Testé : backend bout-en-bout (validation, idempotence, uploads gardés, scoping agent/coordinateur, email Gmail vérifié, lead Odoo réel créé #7655 puis supprimé), frontend au navigateur (Playwright, 0 erreur console, 3 rôles, cascade catalogue OK). Données de test nettoyées (0 vente). Nouveaux paquets : multer. Isolation Odoo notée dans la mémoire moa-odoo-automations-a-migrer. Détail projet : workspace/projets/moa-crm/.
  • - 2026-08-05 : Module Ventes + Coordinateur, jalon 2 (modèle de données + rôles). Migration additive et non destructive dans db.js (idempotente, testée sur copie avant prod, sauvegarde data/moa-crm.db.bak-20260805-131738). (1) Nouvelles tables : ventas (unités vendues déclarées par les agents, 50 colonnes, pipeline coordinateur en_espera_reserva→reservado→documentos_en_curso→contrato_solicitado→contrato_recibido→firmado→comision_cobrada, submit_hash en index UNIQUE partiel pour l'idempotence, prix toujours en USD), documents (pièces jointes génériques ventes ET leads : entity_type/entity_id, kind, stored_path hors web-root, sha256), venta_events (trace d'audit création + changements de statut + IP). (2) Colonnes ajoutées à leads (nullables) : odoo_lead_id, intake_source. (3) Nouveau rôle applicatif coordinateur (constante ROLES dans db.js) : Gary (garyjeanlouis@moa-realestate.com) migré de agent à coordinateur (migration data idempotente, ne touche jamais un admin). provision_agents.mjs préserve désormais ce rôle s'il est rejoué (COORDINATOR_EMAILS). Constantes exportées VENTA_STATUSES/VENTA_STATUS_KEYS/ROLES. Vérifié prod : service actif, health OK, 14 users (2 admin / 11 agent / 1 coordinateur), 59 leads intacts, PRAGMA integrity_check=ok. Les gardes serveur du rôle coordinateur et les endpoints ventes/documents arrivent au jalon 3 (formulaire de vente v2).
  • - 2026-08-05 : Corrections d'audit sécurité + correctness (P1/P2). (1) JWT_SECRET sorti du code : nouveau backend/.env (chmod 600) portant JWT_SECRET (valeur actuelle conservee, non tournee), CORS_ORIGINS, PORT ; lu par dotenv. Pas de fail-fast (fallback conserve pour ne jamais bloquer le boot). Manifeste de rotation : /root/workspace/Audit-apps/_secret_rotation_moa-crm.tsv. (2) Autorisation serveur par role (IDOR corrige) dans server.js : middleware requireAdmin sur /api/dashboard (403 pour non-admin) ; /api/users ne renvoie plus l'annuaire complet a un non-admin (seulement son propre compte, plus de fuite d'emails) ; helpers actingIsAdmin/ownsLead/ensureLeadAccess scopant TOUS les endpoints lead (GET/PATCH/DELETE /api/leads/:id, activities, properties link/unlink, matches) a assignee_id = utilisateur en lecture ET ecriture ; GET /api/leads (liste) force le scope sur le book de l'agent ; a la creation/patch un non-admin ne peut assigner qu'a lui-meme. Admin garde l'acces complet (verifie : admin dashboard 200 / 59 leads / 14 users ; agent dashboard 403 / 11 leads a lui / 1 user ; agent GET/PATCH/DELETE lead d'autrui = 403, stage inchange). (3) Vocabulaire de stages aligne (Pipeline/Dashboard vides sur base neuve corrige) : db.js schema DEFAULT 'sans_contact', seed dist reecrit sur les 10 cles reelles de STAGES (sans_contact/contacte/interesse/relance/rdv_pris/rdv_fait/proposition/contrat/gagne/perdu), refs won/lost/unqualified remplacees par gagne/perdu, lost_reason tire de LOST_REASONS. Defaut serveur POST /api/leads passe de 'new' a 'sans_contact'. Migration idempotente ancien->nouveau vocabulaire au boot (backup .db fait ; no-op sur la base live, deja canonique). CSS : classes .stg-* refaites sur les 10 vraies cles. (4) Durcissement HTTP : helmet() (CSP tunee : script self, style self+inline+fonts.googleapis, font gstatic, img self+data ; referrer-policy no-referrer), x-powered-by desactive, trust proxy 1. (5) CORS restreint aux origines reelles (CORS_ORIGINS, defaut crm-moa.panelbay.com + ancien tenga.run). (6) Rate-limit (express-rate-limit) sur /api/login et /api/change-password : 40/15min par IP client reelle (X-Forwarded-For, jamais globalement 127.0.0.1 — verifie : 9.9.9.9 -> 429 apres 40, autre IP toujours 401). (7) Messages d'erreur catalogue generiques (plus de e.message brut au client ; log serveur). (8) Accessibilite : nav focusable (role=button/tabindex=0 + activation clavier Enter/Espace), lignes de tableau prospects/unites activables au clavier, labels login for/id, focus-visible uniforme ; modale role=dialog/aria-modal + fermeture Echap + piege de focus (Tab) ; @media (prefers-reduced-motion) ; kmoney route via Intl (compact, plus de $ code en dur) ; zebra striping + tabular-nums. Nouveaux paquets : helmet, express-rate-limit. Cache-bust app.js?v=20260805a, i18n.js?v=20260805a. Restart moa-crm effectue, node --check OK, healthcheck OK. REPORTE (stabilite / hors scope demande) : item #5 (JWT en query string des docs — laisse tel quel pour ne pas casser le telechargement en nouvel onglet ; le header Authorization est deja accepte en fallback) ; item #10 (valeurs de select FR figees — non externalise pour ne pas desynchroniser les valeurs deja stockees en base ; aucune traduction ES demandee) ; onglet/nginx = a faire par l'orchestrateur (aucune conf hors app touchee).
  • - 2026-08-04 : migration vers panelbay.com. Nouvelle URL https://crm-moa.panelbay.com (HTTPS Cloudflare + Let's Encrypt). L'ancienne https://crm.bouthors-m.tenga.run reste active en parallele (double-service) le temps de la transition.
  • - 2026-08-25 : Relances automatiques (phase 1 : proposition + validation par agent). Demande Matt : automatiser les relances des leads qui ne répondent jamais, in fine en envoi WhatsApp 100% auto, mais avec une période de validation de la voix et des relances AVANT l'auto, et uniquement pour les silencieux. Choix Matt : message à la voix de l'agent en suivant le process de relance MOA Academy (J1→J5), chaque agent valide sa propre file, rythme selon MOA Academy (séquence 5 jours, 1 étape/jour). Livré : moteur backend/relance_engine.mjs (détection leads silencieux + cadence + garde-fous), générateur backend/scripts/relance_gen.py (OpenAI voix agent + Academy, repli déterministe sans clé, pointe le fichier Academy déplacé dans _archive/), tables relance_queue + relance_settings (db.js), endpoints /api/relances[/count|/refresh|/:id/send|/:id/skip|/:id/stop] (server.js), onglet frontend Relances (app.js renderRelances + badge nav, api.js, i18n.js clés rel.*/nav.relances, styles.css), cron nocturne 0 7 * * * → relance_build.mjs. Envoi = via l'instance Evolution de l'agent (numéro connu du lead) ; canal appel/audio = marqué fait sans envoi ; envoi refusé si pas d'instance open. Testé de bout en bout (moteur : qualifie 1 silencieux, exclut répondu-récemment / trop-frais / séquence-finie ; HTTP : list, count, garde-fou d'envoi 400 sans session, skip avance l'étape, appel marque fait, stop met step 5 ; UI Playwright : onglet visible, rend le header + état vide, 0 erreur JS ; artefacts de test nettoyés). Cache-bust 20260825relances, SW moa-crm-v51. Restart moa-crm.service OK, node --check OK. Reste pour phase 2 (sur go Matt) : bascule mode=auto (envoi sans clic) avec plafond/jour, horaires jour, stop sur réponse, détection opt-out, liste blanche perso.
  • - 2026-08-25 (soir) : Pivot relances vers 100% auto piloté par le Claude de l'agent + onglet caché aux agents. Demande Matt : rendre l'onglet Relances indisponible aux agents (garde le contrôle), et pour un TEST avec Monica Ayala viser du 100% automatique dont la voix est calibrée par SON Claude connecté à l'API du CRM. Livré : (1) onglet Relances admin only (showFor.relances = admin, cache-bust 20260825relances2, SW v52). (2) Profil de voix par agent : table agent_voice_profiles, 3 outils MCP (get_my_writing_samples, save_my_voice_profile, get_my_voice_profile) scopés ctx.uid, injection dans relance_gen.py (_voice_block) + chargement dans relance_engine.mjs (voiceProfile). Testé : génération avec profil Monica factice → message clairement dans sa voix (« Te cuento… », « cualquier cosa me decís 🙌 », « Soy Monica de MOA »). (3) Guide de connexion Monica workspace/MOA-relances-monica-voix.md (plan payant requis, URL /mcp, prompt d'analyse de voix en espagnol). État Monica : WhatsApp connecté (595995606450), 820 messages sortants (matière voix), 24 leads relançables. node --check OK, restart moa-crm.service OK. RESTE (attend le go Matt) : activer l'envoi 100% auto pour Monica (mode auto scopé à son user) une fois son profil de voix enregistré, avec garde-fous (horaires jour, plafond/jour, stop sur réponse, opt-out, liste blanche perso). L'envoi réel de relances à de vrais prospects n'est PAS activé sans go explicite.
  • - 2026-08-25 (soir 2) : Bascule vers l'API à clé (pas le MCP) pilotée par le Claude de l'agent. Matt : le connecteur MCP ne marche pas chez eux, passer par l'API à clé avec un vrai endpoint d'ENVOI, et que Monica gère tout depuis son assistant Claude. Côté CRM (server.js, routeur /api/v1) : GET /relances/voice-samples, GET|POST /relances/voice-profile, GET /relances/pending (leads silencieux via evaluateLead), POST /relances/send (envoi WhatsApp via l'instance de l'agent + avance séquence). Garde-fous serveur : 409 si le lead a répondu, 429 si plafond daily_cap (25/j), 423 hors 9h-20h sauf force, 400 si WhatsApp non connecté. Clé API émise pour Monica. Côté assistant Monica (workspace/monica-assistant, re-zippé) : skill .claude/skills/relances/SKILL.md (étape 0 apprend sa voix, puis pending→rédige dans sa voix→send ; jour 1 = validation ; garde-fous), data/crm-access.json (base_url + clé), CLAUDE.md maj. Testé avec sa clé (me, voice-samples, voice-profile save+get, pending = 16 vrais silencieux, send canal call, garde-fou 409). Profil de voix de test nettoyé. Onglet Relances toujours admin-only. node --check OK, restart OK. Reste : Monica màj son pack, lance l'analyse de voix, puis jour-1 (validation) avant 100% auto.
  • - 2026-08-25 (soir 3) : Envoi 100% auto cote serveur (phase 2). Suite au refus (normal) du Claude de Monica d'envoyer seul en se faisant passer pour elle, Matt a choisi que le CRM envoie tout seul de facon deterministe. Ajout : runAutoSend() dans relance_engine.mjs + relance_autosend_run.mjs + cron 30 10 * * * (log data/relance_autosend.log) ; colonne relance_settings.auto_confirmed. Ne tourne que pour agents mode=auto+enabled+auto_confirmed=1. Garde-fous : 9h-20h, plafond 25/j, stop si le lead a repondu, opt-out (scan entrants), J2/J3 (audio/appel) auto-avances sans envoi (touche humaine), instance WhatsApp open requise, message dans la voix de l'agent. Teste : les 3 gates (not_confirmed / wa_not_open / out_of_hours) bloquent tout envoi ; Monica reste auto_confirmed=0 donc rien ne part. Mise en route (apres que Monica a enregistre sa voix) : valider le jour-1 dans l'onglet Relances admin, puis passer auto_confirmed=1. node --check OK, restart OK.
  • - 2026-08-25 (soir 4) : Bascule modele "agent-redige, CRM envoie" (raison COUT, decision Matt). Le CRM-genere-via-OpenAI faisait payer le cout d'IA a l'entreprise, par message. Nouveau modele : c'est le Claude de l'agent (abonnement forfaitaire, sur son PC) qui redige les messages ET les reponses, les montre a l'agent (qui valide "dale" en direct : resout aussi le garde-fou du modele), puis le CRM les envoie. Le CRM redevient un tuyau, sans generation IA. Changements : crons relance_build (7h) et relance_autosend (10h30) RETIRES (plus de generation OpenAI cote CRM ; le code buildQueue/runAutoSend reste dispo si besoin futur). Monica repassee mode='review', auto_confirmed=0 (aucun envoi serveur auto). Nouveaux endpoints /api/v1 : GET /relances/replies (leads qui ont repondu et attendent une reponse, avec history), POST /messages/send ({lead_id, message} = message libre/reponse via l'instance de l'agent, sans logique de sequence). Le skill relances du pack Monica (workspace/monica-assistant, re-zippe) reecrit pour ce flux : etape 0 voix, section A relances (pending -> rediger -> MONTRER a Monica -> sur "dale" send force:true), section B reponses (replies -> rediger -> valider -> messages/send). Teste : /relances/replies = 10 vrais leads en attente de reponse. Cout d'IA cote CRM ~ 0 (l'agent porte la redaction). Reste : Monica maj son pack + lance l'analyse de voix, puis usage quotidien (elle lance, valide, le CRM envoie).
  • - 2026-08-26 : Fiche vente (Ventes MOA) : bouton d'ajout manuel d'annexe (cochera/baulera) pour Gary/admin. Dans openMvtModal (frontend/js/app.js), bouton + Cochera / Baulera dans l'en-tete .mh, a cote du nom + numero d'unite, visible seulement si la fiche n'est pas deja une annexe et si l'utilisateur peut ecrire (canAddAnnexe). Clic -> mini formulaire (mvtToggleAnnexeForm) : cases Cochera / Baulera, N° et Prix optionnels -> API.moaops('POST','/ventes/:id/annexe', {typologies, unite, prix}) (proxy -> moa-ops). Succes : toast + fermeture + reload du board (l'annexe apparait rattachee a la vente). i18n vm.addAnnexe/vm.annexe*/vm.cancel, CSS .mvt-annx-add/.mvt-annx-form scope .mvt-scope. Cache-bust 20260826annexe, SW v53. Aucun changement backend CRM (proxy generique). Teste Playwright (bouton present, formulaire s'ouvre) + chaine complete via proxy.
  • - 2026-08-26 (v2) : Fiche vente : refonte du formulaire d'ajout d'annexe. Au lieu de 2 cases a cocher, le bouton ouvre un formulaire ou l'on choisit le type via + Cochera / + Baulera : chaque clic ajoute UNE ligne (tag du type + N° + Prix USD, les deux OBLIGATOIRES), supprimable. On peut empiler plusieurs items (ex. 1 cochera + 1 baulera). mvtToggleAnnexeForm reecrit -> API.moaops('POST','/ventes/:id/annexe',{items:[{typologie,unite,prix}]}). Validation cote client (numero+prix requis) ET serveur. i18n vm.annexeAddLabel/Num/PrixReq/Req. CSS .annx-add-row/.annx-line/.annx-tag/.annx-rm. Cache-bust 20260826annexe2, SW v54. NB : verifie que Gary peut bien editer le prix des ventes existantes (champ present, actif, sauvegarde OK cote serveur ET UI en tant que Gary) : le blocage signale n'a pas pu etre reproduit sur la version deployee (probable cache navigateur ancien cote client).
  • - 2026-08-26 (v3) : Suivi des assignations + panneau d'acces agents (admin). (A) Onglet Assignations (admin) : GET /api/admin/assignments -> par agent, leads attribues cette semaine / mois / trimestre (table lead_assignments, journalisee a chaque attribution via logAssignment sur create + PATCH assignee ; backfill initial depuis leads.created_at, 665 lignes) + signaux de perf (leads actifs, RDV+, ventes gagnees, taux de conv). Sert a equilibrer la distribution manuelle. (B) Onglet Acces agents (admin) : active/desactive les onglets que voient les agents SANS passer par l'AIOS. Modele defaut global + exceptions par agent : tables feature_flags (global) + feature_flags_user (override). Onglets togglables : pipeline, leads, clients, tasks, assistant, properties, ventas, promoteurs. /api/me renvoie features (resolu : override agent sinon defaut global ; admin = tout). applyGating + le gate 'coming soon' pilotes par featureOn(view) (remplace l'ancien fullAgent() en dur ; les 12 full-agents actuels ont un override seede pour preserver leur acces). Endpoints admin : GET /api/admin/feature-flags, POST /api/admin/feature-flags/global, POST /api/admin/feature-flags/user (enabled=null -> retour defaut). API adminAssignments/featureFlags/setFeatureGlobal/setFeatureUser, i18n nav.assignations/acces, asg.*, acc.* (ES/FR/EN), CSS .asg-*/.acc-* (switch + segmented). Cache-bust 20260826access, SW v55. Teste : journalisation (create + reassign), backfill 665, endpoints (assignments 11 agents, flags), chaine override -> /me (pipeline off par defaut, on via override, clear -> defaut), UI Playwright admin (2 onglets, table 11 lignes, 8 switches globaux, 12 agents). Restart moa-crm OK, node --check OK.
  • - 2026-08-26 (v4) : Regroupement d'onglets (quick wins de l'audit nav). Barre passee de 19 a 16 entrees. (1) Ventas + Ventes fusionnes en une seule entree ventes : dispatch par role (coord/admin -> board moa-ops renderVentesMoa ; agent -> declaration renderVentas si flag ventas). Fin de la confusion des deux onglets quasi homonymes. Refs mises a jour : defaultView, COORD_VIEWS (ventesmoa->ventes), navigate redirects, titles, new-btn, dispatch, reload annexe (state.view). Flag agent garde la cle 'ventas' ; label serveur AGENT_VIEW_LABELS.ventas -> 'Ventes'. (2) Clients sorti de la nav -> sous-onglet sous Prospects (segmented subNav [Prospects | Clients] en tete de renderLeads/renderClients, affiche si l'utilisateur a les deux). (3) Assignations sorti de la nav -> sous-onglet sous Tableau de bord (subNav [Tableau de bord | Assignations] en tete de renderDashboard/renderAssignments). Helper subNav(pairs, active) + CSS .subnav/.subnav-btn. i18n nav.ventes. Cache-bust 20260826regroup, SW v56. Teste Playwright 3 roles : admin (16 onglets, Ventes=board, 2 sous-onglets OK), Gary (6 onglets, defaut Ventes=board, pas d'entree morte), agent full (Ventes=declaration, sous-onglet Prospects/Clients). node --check OK, restart OK. Reste (non fait, propose) : refonte nav a 2 niveaux (5 poles) si Matt valide plus tard.
  • - 2026-08-27 (v2) : Retour arriere : les agents revoient TOUS les promoteurs. Le filtre "avec contrat" ajoute plus tot ce jour sur l'endpoint agent GET /api/promoter-terms/public est retire (demande de Matt). Les agents voient de nouveau la liste complete des promoteurs, avec ou sans contrat signe. Cote admin/assistante (Valentina), la colonne "Contrat", la date de fin confidentielle et le toggle "Cacher les promoteurs sans contrat" restent inchanges. Aucun changement front (l'endpoint reste la seule source de verite pour les agents). node --check OK, restart moa-crm OK.
  • - 2026-08-27 (v3) : Fix relances API : l'historique de conversation renvoyait le DEBUT au lieu de la FIN. Les endpoints GET /api/v1/relances/pending et /relances/replies construisaient history avec ORDER BY ts ASC LIMIT 20/30 = les 20/30 messages les plus ANCIENS. Pour tout lead avec plus de 20/30 messages (frequent), les derniers echanges (dont les derniers messages envoyes) etaient tronques : le Claude de l'agent ne les voyait pas et redigeait sans en tenir compte. Corrige en prenant les N plus RECENTS (ORDER BY ts DESC LIMIT N) puis .reverse() pour l'ordre chronologique, + ajout du champ ts par message. Verifie sur le lead le plus bavard (155 messages) : l'historique se termine maintenant bien sur le vrai dernier message. node --check OK, restart OK. Aucun changement cote pack agent (meme forme de reponse, juste la bonne tranche).
  • - 2026-08-28 : Bouton "Nouvelle vente" (board Ventes MOA, Gary + admin). Ajout d'un bouton "+ Nouvelle vente" dans renderVentesMoa ouvrant un formulaire complet (openVenteCreateModal) : unite, client, agent (select des agents existants), projet (select), typologie, prix, devise, statut, affilie, entrega, date de suivi, whatsapp, email, 2e acheteur, langues (cases), commentaires. Envoi via le proxy existant POST /api/moaops/ventes/create (cote moa-ops). Options chargees via GET /api/moaops/ventes/options. Obligatoires : unite + client + agent. i18n vm.newSale, vm.f.* (ES/FR/EN). CSS .vc-*. Cache-bust 20260828vente, SW v62. Aucun changement backend CRM (proxy moaops generique deja en place). Teste end-to-end (create Airtable reel + suppression du record de test) OK.
  • - 2026-08-31 : Nouveau poller : clients Resident Paraguay bientot au Paraguay -> lead MOA. Demande Matt : que tout client RP venant au Paraguay pour sa residence soit propose un RDV immobilier avec un agent MOA. Livre sources/rp_residency_moa_import.py (cron 45 6 * * *, log rp_residency_moa.log) : relit l'agenda Google RP (resident.paraguay@gmail.com) via create_rdv.py --list (app rp-dashboard, deja authentifie, source=rp-dashboard uniquement), retient les evenements a J+10 max de type "Residence T"/"Renouvellement residence T" (depot) et "RUC - Arrivee de la personne" (sous-etape llegada). Exclusion explicite : "SUACE Investor Pass" (toutes sous-etapes) et les autres sous-etapes RUC (demanda/retiro) ne declenchent rien. Pour chaque evenement retenu, resout le caso_id (colle en extendedProperties) vers la fiche Airtable Casos (nom/email/tel/langue), enrichit avec l'historique existant dans le CRM prospects RP de Younes (rp-crm/data/rp-crm.db, match email/tel) si connu, redige un message d'invitation suggere (FR/EN/ES selon Idioma) et pousse le tout via POST /api/leads/ingest (source=rp_residency_visit, tag='RP - venue Paraguay') : le lead atterrit "a assigner" comme les autres sources, note = contexte + message suggere. Envoi 100% MANUEL : l'agent assigne redige/envoie lui-meme, rien ne part automatiquement. Anti-doublon a vie : un client (tel/email normalise) n'est traite qu'une seule fois par ce pipeline (etat local sources/rp_residency_moa_state.json), meme si un nouvel evenement futur reapparait (renouvellement, 2e RDV RUC). Teste --dry-run OK (0 evenement en attente actuellement) + unitaire classify()/fetch_caso()/build_note() sur des vraies donnees Casos. Aucun acces Google Calendar duplique (delegue entierement a create_rdv.py), aucune ecriture Airtable (lecture seule PAT).
  • - 2026-09-01 : Fiabilisation du scraping catalogue en amont (moa-catalog), aucun changement cote CRM. Suite a un signalement Matt (drives illisibles, unites "dispo" qui ne le sont pas, mauvais numeros), audit complet + corrections cote source : RIZ (Porta) et Petra Urbana passes du manuel au cron quotidien (isolation d'erreur par tour/deck), fix du 403 recurrent sur les documents Civis, alerte auto en cas d'echec tulugar, nouveau check_catalog_health.py (alerte notify.py quotidienne). Pannes reelles trouvees et documentees pour suite : Porta 6 a change de structure (parseur a reecrire), 2 decks Petra pointent vers des fichiers introuvables (a verifier avec le promoteur). Detail complet : moa-catalog/MANUAL.md CHANGELOG + skill moa-catalog-drives.
  • - 2026-09-02 : Mode edition explicite sur la fiche vente (board Ventes MOA de Gary), fin de l'auto-save par champ. Demande de Matt pour Gary : avant, chaque changement de champ (openMvtModal, dossier client + cycle de vie : typologie, prix paye, whatsapp, email, 2e acheteur, langues, entrega, affilie, commission, devise, dates, commentaires) s'enregistrait immediatement au blur/clic. Desormais la fiche s'ouvre verrouillee (champs desactives, comme la supervision admin lecture seule) ; un bouton crayon en haut a droite de la fiche (#mvt-edit-btn, a cote de la croix de fermeture) passe la fiche en mode edition (deverrouille les champs, capture une photo des valeurs dans _mvtSnapshot). Aucune sauvegarde tant que "Valider" (#mvt-edit-save) n'est pas cliquee : ne poste que les champs reellement changes vs la photo (diff contre _mvtSnapshot, evite de re-ecrire des champs inchanges et de polluer le journal Airtable cote moa-ops qui logue chaque vente.update avec old/new). Bouton "Annuler" revient aux valeurs d'origine sans rien envoyer. Garde-fou fermeture : si la fiche est en edition avec des changements non valides et que Gary ferme la fenetre (croix, clic hors modale, Echap), une popup de confirmation (confirm(), coherent avec le reste de l'app) demande s'il veut vraiment perdre ses modifications ; annuler la popup laisse la fiche ouverte. Ne concerne que le dossier client/cycle de vie : le stepper de statut, l'ajout d'evenements au suivi, l'ajout d'annexe et l'upload de justificatif de commission restent des actions immediates (inchangees), ce sont des actions pas des edits de champ. Uniquement actif quand writable (Gary/agent) ; la supervision admin (lecture seule) n'a pas de bouton edition. i18n vm.edit/vm.editSave/vm.unsavedConfirm (ES/FR/EN, frontend/js/i18n.js). CSS .mh-actions/.mvt-edit-btn/.mvt-edit-bar (frontend/styles.css). node --check OK sur app.js et i18n.js. Cache-bust 20260902editmode (styles.css + i18n.js + app.js dans index.html), SW v64. Fichiers statiques (express.static, Cache-Control: no-cache), pas de redemarrage moa-crm.service necessaire.
  • - 2026-09-02 : Lien Drive promoteur visible par les agents (pas seulement l'admin). Matt a demande que le Drive Vitrium soit accessible aux agents depuis le CRM. Constat : promoteurs.drive_url existait deja en base (moa-catalog/catalog.db) et etait servi par /api/catalog/promoters (route ouverte a tout utilisateur connecte), mais rien ne l'affichait cote frontend : seul l'onglet admin "Promoteurs" (/api/promoteurs, requireAdmin) et le panneau "conditions commerciales" (/api/promoter-terms, admin+assistante) montraient un lien Drive, tous deux inaccessibles aux agents. Ajout dans renderCatProjects (vue promoteur du catalogue Biens, accessible a tous les roles connectes) : un lien "📁 Drive ↗" juste sous le fil d'Ariane si promoteur.drive_url est renseigne, sinon rien (pas de lien casse). N'ouvre PAS l'onglet admin aux agents (evite de leur exposer les commissions/conditions commerciales) : uniquement le lien Drive, au bon endroit. Rempli pour Vitrium (https://drive.google.com/drive/folders/1FHKitmDYxsyr0XJvDk5pP9QJ_XBCvnT1) dans catalog.db (promoteurs.drive_url) et dans backend/data/moa-crm.db (promoter_terms.drive_url, pour le panneau admin). A refaire au cas par cas pour les autres promoteurs si Matt le demande (ou en masse si beaucoup de drives a renseigner). node --check OK sur app.js. Cache-bust 20260902drivelink (styles.css + i18n.js + app.js dans index.html), SW v65. Fichiers statiques, pas de redemarrage moa-crm.service necessaire.
  • - 2026-09-02 : Nouvel onglet "Avis" (Gary + admin) : suivi qualitatif des demandes d'avis client (e-reputation). Demande Matt : MOA vend un produit cher a peu de clients (immobilier), donc pas de mass-mailing d'avis ; il fallait un outil qui aide Gary a ne pas oublier de demander un avis (WhatsApp/appel/en personne, jamais d'envoi automatique) au bon moment, avec relance qualitative. Analyse des bonnes pratiques (produit haut ticket/faible volume) faite en amont avec Matt : demande personnelle par la personne de contact, timing = pic emotionnel (dossier clos), une seule relance puis on arrete, lien direct + suggestion de mots-cles, couplage avec la demande de parrainage. Trigger d'eligibilite choisi par Matt : statut de vente = "Commission recue" (dossier 100% clos cote MOA), pas seulement l'entrega. Nouvel onglet "Avis" dans la nav Gary/admin (COORD_VIEWS, showFor.avis), 3 sections calculees a la volee (aucun pipeline complexe) : À demander (Commission recue, aucune ligne locale), En attente de réponse (badge rouge "À relancer" si aucune action depuis 10 jours), Historique (avis obtenu avec lien reutilisable en temoignage, ou sans suite). Actions inline : Demander (capture le canal whatsapp/appel/en_personne/autre via prompt(), coherent avec le style existant de l'app type mvtPayCommission), Relancer (compteur), Avis obtenu ✅ (lien optionnel), Sans suite. 4 KPI en tete (a demander / en attente / a relancer / obtenus). Backend 100% nouveau et independant : apps/moa-ops/avis.js (SQLite local data/avis.db, table avis_requests, AUCUNE ecriture Airtable, AUCUN envoi automatique), endpoints GET /api/avis/meta, GET /api/avis/board (reutilise loadRows() desormais exportee de ventes.js, meme cache 60 s), POST /api/avis/:id ({action, channel?, review_url?, note?}). Journalise dans le meme data/journal.jsonl que les ventes (avis.demande|relance|obtenu|sans_suite). Monte dans server.js (moa-ops), couvert automatiquement par le proxy generique existant /api/moaops/* cote CRM (aucun changement necessaire dans backend/server.js du CRM). i18n avis.*/nav.avis (ES/FR/EN, frontend/js/i18n.js). Coexiste avec l'ancien statut Airtable "Avis demandé" et le jalon timeline "Avis client demandé"/"Relance client" (dossiers.js), rien retire, mais le suivi actif passe desormais par ce module (detail complet et rationale : apps/moa-ops/MANUAL.md). node --check OK (app.js, i18n.js, avis.js, ventes.js, server.js moa-ops). Teste de bout en bout en direct sur 127.0.0.1:8338 (contournement du proxy CRM, cle interne) : /api/avis/board renvoie les vraies ventes "Commission recue" eligibles (verifie sur donnees reelles Airtable) ; cycle complet demande -> relance -> obtenu sur un sale_id de test (jamais un vrai dossier) avec verification des rejets 400 (action/canal invalides) et des entrees journal, puis ligne de test supprimee de avis.db. moa-ops.service redemarre. Cache-bust 20260902avis (styles.css + i18n.js + app.js dans index.html), SW v66.
  • - 2026-09-07 : Nouvel onglet "Briefing hebdo" (ADMIN uniquement) : vision des ventes conclues de la semaine, pour préparer le brief du lundi (Seb). Demande Matt : donner à Sébastien (sebastienbeaupied@moa-realestate.com, déjà role=admin) une vue pour préparer les briefings de début de semaine, montrant les ventes conclues de la semaine précédente. Définition d'une "vente conclue" = une fiche de vente CRÉÉE par un agent dans son CRM (ventas.created_at), qui matérialise une réservation d'appartement qui vient d'être faite (référence explicite choisie par Matt, PAS le statut/l'entrega ni la date de signature). BACKEND : nouvelle route GET /api/dashboard/weekly-sales?weekOffset=N (auth+requireAdmin), fenêtre = semaine calendaire lundi 00:00 -> lundi 00:00 en heure locale America/Asuncion (created_at stocké en UTC, comparé via le modificateur SQLite datetime(created_at,'localtime') ; le service tourne dans le fuseau système). weekOffset : 0 = semaine en cours, -1 = semaine précédente (défaut), borné [-260, 0] (jamais dans le futur). Renvoie {weekOffset, weekStart, weekEnd, isCurrentWeek, count, totalUsd, byAgent[{agent_id,name,color,count,totalUsd}], sales[...]}. FRONTEND : 3e sous-onglet briefing dans la subnav du Dashboard (à côté de Dashboard / Assignations), gardé isAdmin() (dispatch renderView, navigate, titre). renderWeeklyBriefing : barre de navigation entre semaines (‹ / › + bouton "semaine en cours", › désactivé sur la semaine courante), 3 KPI (ventes nouvelles / volume USD / agents ayant vendu), récap par agent (pastille couleur + nb + volume), détail des ventes (date de création, agent, client, projet·unité, prix, statut ; clic = openVentaDetail(id,{coord:true})). API.weeklySales(weekOffset) dans api.js, i18n nav.briefing+brief.* (ES/FR/EN, ES = référence). Testé de bout en bout sur 127.0.0.1:8310 (JWT admin Seb signé avec le JWT_SECRET réel) : weekOffset=0 (0 vente cette semaine), -2 (1 vente, Mathis Delettre, Æther by Civis 37 J, 188 859 USD, byAgent + totalUsd corrects) ; découpage local vérifié (une vente créée le 2026-08-24 tombe bien dans la semaine du 24 au 30). node --check OK (server.js, app.js, api.js, i18n.js). moa-crm.service redémarré (route backend). Cache-bust 20260907briefing (i18n.js + api.js + app.js dans index.html), SW v68.
  • - 2026-09-07 : Nouvel onglet "Suivi agents" (ADMIN, 4e sous-onglet du Dashboard) : dashboard de performance des agents, inspiré du tableau de bord manuel de Seb (Netlify/Supabase) mais branché sur les VRAIES données du CRM. Demande Matt (« dashboard de suivi des agents pour le CRM MOA admin », référence = dashboard manuel de Seb). Choix Matt : source HYBRIDE (auto pour ce que le CRM connaît, manuel pour les appels), et 4 sections (funnel/KPI semaine·mois, fiches par agent, leaderboard, plans d'action + notes coaching). Définitions des métriques : contacts = 1er vrai passage d'un lead en "Contacté"+ dans la fenêtre (tous leads, mouvements réels façon agent_goals.js : entrée validée si dwell ≥ MIN_DWELL_MIN=120min OU progression avant) ; rdv = entrée réelle en rdv_pris ; ventes = fiches ventas CRÉÉES (même déf. que Briefing) ; appels = MANUEL (le CRM ne trace pas les appels tél), saisi par agent/semaine. Score pondéré contact×1/appel×2/RDV×5/vente×15. Objectifs réutilisés de l'onglet Objectifs (agent_goals : goal_leads→contacts, goal_rdv, goal_ventes ; mois = somme des semaines). BACKEND : nouveau module backend/agent_perf.js (calcul + tables) ; routes GET /api/perf, POST /api/perf/appels, GET|POST /api/perf/plans, PATCH|DELETE /api/perf/plans/:id, POST /api/perf/notes, DELETE /api/perf/notes/:id (toutes requireAdmin). 3 nouvelles tables : agent_appels(agent_id, week_start, appels), agent_action_plans, agent_coaching_notes. Fenêtres en heure locale (epoch ms, cohérent avec les instants UTC des events) ; les tables hebdo (appels/objectifs, clé = lundi YMD) sont comparées par chaîne de date et non epoch, pour éviter le décalage tz (bug corrigé pendant le dev : un lundi stocké en UTC-midnight tombait juste avant la borne locale-midnight → appels invisibles). FRONTEND : renderAgentPerf (app.js) = 4e sous-onglet perf dans la subnav du Dashboard, gardé isAdmin() (dispatch renderView, guard navigate, titles, hide #new-btn) ; toggle Semaine/Mois + nav de période ; KPI équipe vs objectif ; funnel avec taux de conversion ; leaderboard (médailles, score, badges actif/inactif) ; fiches par agent (mini-stats vs obj, saisie appels inline en vue semaine, sparkline contacts/semaine SVG, taux de conversion, tendance hausse/baisse) ; section management (formulaire + liste plans d'action avec statuts, notes de coaching + patterns par blocage). api.js : perf/setAppels/perfPlans/perfCreatePlan/perfUpdatePlan/perfDeletePlan/perfCreateNote/perfDeleteNote. i18n nav.perf + perf.* (~55 clés, ES/FR/EN, ES=référence). CSS .perf-* (self-contained, palette dérivée du thème forest/gold du CRM). Testé de bout en bout sur 127.0.0.1:8310 (JWT admin réel) : /api/perf semaine/mois (semaine -1 = 21 contacts / 12 RDV, leaderboard trié par score, sparklines OK), écriture appels (12→score 24), CRUD plans (create/patch/delete + statut invalide→400) et notes ; rendu navigateur (Python-Playwright, Playwright MCP KO en root) : onglet visible, KPI/funnel/leaderboard/fiches/management rendus, 0 erreur console/pageerror, base laissée propre (plans/notes de test supprimés). node --check OK (agent_perf.js, server.js, app.js, api.js, i18n.js). moa-crm.service redémarré. Cache-bust 20260907perf (styles.css + i18n.js + api.js + app.js), SW v69.
  • - 2026-09-11 : Raphaël Sterckx branché sur les alertes WhatsApp d'attribution de lead. Demande Matt : que Raphaël reçoive l'alerte WhatsApp classique envoyée aux agents quand on lui assigne un lead. Constat : Raphaël existait déjà comme utilisateur agent (id sfIJsaUWIb8Oxxmt, raphaelsterckx@moa-realestate.com) mais n'avait aucun numéro joignable pour notifyAssignment (pas de ligne team_members liée, pas de session WhatsApp propre appairée), donc resolveContactForUser renvoyait null et aucune alerte ne partait. Correctif = 1 ligne dans team_members (INSERT direct dans backend/data/moa-crm.db, id Sj0hzgAK5J9mUYTe) : name='Raphaël Sterckx', phone='+595974692539' (numéro fourni +595 974 692539, normalisé façon normalizePhone), category='equipe', user_id='sfIJsaUWIb8Oxxmt' (lien explicite = priorité 1 de resolveContactForUser). Vérifié : resolveContactForUser({id:'sfIJsaUWIb8Oxxmt'}) renvoie bien ce contact ; instances WhatsApp émettrices ouvertes présentes (dont celle de Matt admin 595991711964), donc pickSenderInstance a de quoi envoyer. Aucun redémarrage nécessaire (les team_members sont relus à chaque attribution). Aucune écriture Airtable. Rappel du mécanisme : l'alerte part de l'instance WhatsApp de celui qui attribue (repli : n'importe quelle instance ouverte), pas d'auto-alerte si on s'assigne à soi-même.
  • - 2026-09-15 : Règle Matt : par défaut, tout compte agent a accès à TOUS les onglets agents (plus seulement WhatsApp). Déclencheur : le compte de Monica Lucena (monicalucena@moa-realestate.com, role=agent, id KP72Gbg51zeE5CvM) n'avait de fait accès qu'à l'onglet WhatsApp. Cause racine : le défaut GLOBAL historique de feature_flags (seed db.js) mettait pipeline/leads/clients/tasks à 0 (écran "à venir"), chaque agent devant être débloqué à la main via une exception feature_flags_user ; tous les autres agents avaient ces exceptions, pas Monica Lucena. Correctif en 3 temps : (1) backend/db.js — DEFAULTS passé à tous les onglets agents = 1 (pipeline/leads/clients/tasks/assistant/properties/ventas/promoteurs), commentaire mis à jour (règle Matt). (2) feature_flags global forcé à 1 sur les 8 vues agents dans la base live (idempotent). (3) Exceptions explicites feature_flags_user=1 ajoutées pour Monica Lucena sur les 8 vues (parité avec ses pairs + garde-fou si le global venait à changer). Les fonctions admin ne sont jamais concernées : les onglets admin (dashboard, agents/suivi, team, relances, accès, coord, commissions, objectifs, avis, briefing, assignations, perf) sont gardés par RÔLE (isAdmin/canCoord), pas par ces flags ; un role=agent ne les voit donc jamais. Vérifié : resolveFeatures renvoie les 8 vues à true pour Monica Lucena ; moa-crm.service redémarré, /api/health 200. NB côté client : un agent déjà connecté doit recharger l'app (ou se déconnecter/reconnecter) pour que sa session récupère les onglets (les features sont relus à chaque /api/me, donc un simple rechargement suffit).
  • - 2026-09-18 : Upload de contrats promoteurs : formats Word (DOC/DOCX) désormais acceptés (signalement Valentina). Déclencheur : Valentina (role=assistant, onglet Promoteurs) recevait une erreur en uploadant un contrat. Diagnostic : ses droits sont OK (test d'upload d'un PDF réel avec un JWT valentina → 200) ; le blocage venait du validateur de format de backend/uploads.js (sniff/saveBuffer, validation par magic bytes, l'extension n'est pas de confiance) qui n'acceptait que PDF/JPG/PNG/WEBP. Un contrat Word (ou une photo iPhone HEIC) était donc rejeté en 415. Correctif (choix Matt = ajouter Word) : ajout de deux entrées SIG dans uploads.js — docx (conteneur ZIP, signature PK) et doc legacy (conteneur OLE2, signature D0 CF 11 E0). Ces signatures étant partagées avec xlsx/pptx/xls, on confirme magic bytes ET extension (/\.docx$/i, /\.doc$/i) pour ne laisser passer que du Word ; sniff(buf, originalName) reçoit désormais le nom d'origine (rétro-compatible, l'autre appelant sniff(f.buffer) en vision WhatsApp ne garde que les images). Messages 415 mis à jour (route contrats + persistFiles reserva) et hint i18n pt.contractFormats (ES/FR/EN) + accept du sélecteur de fichier (frontend/js/app.js) élargis à .doc/.docx. HEIC volontairement NON ajouté (nécessiterait une conversion serveur ; Valentina peut passer une photo iPhone en JPG). Testé de bout en bout sur 127.0.0.1:8310 (JWT valentina réel) : .docx réel (zip) accepté 200, .pdf toujours 200, et un ZIP renommé .txt correctement rejeté 415 ; fichiers de test supprimés (disque + table documents) sur le promoteur "Creo". moa-crm.service redémarré (uploads.js chargé au boot). NB client : Valentina doit recharger le CRM (SW network-first, un simple rechargement récupère le nouveau app.js/i18n.js).
  • - 2026-09-21 : Rôle finance (Giannina, responsable financière) + prix "tout inclus" sur le board Ventes de Gary. Demande Matt (2 points). Point 1 — accès Giannina : nouveau rôle finance, accès LECTURE SEULE au SEUL onglet Ventes (board renderVentesMoa, source moa-ops), exactement la même vision que Gary sur les ventes (toutes les ventes, prix tout inclus, fiche avec détail), mais AUCUN pouvoir de coordination. BACKEND (backend/server.js) : actingIsFinance + finance ajouté à canSeeAllSales (lecture de toutes les ventes + téléchargement des justificatifs venta_moa) ; nouveau middleware requireMoaOpsAccess (admin+coordinateur = tous droits ; finance = GET seulement) appliqué au proxy /api/moaops/*, à GET /api/admin/moa-ventes (board) et à GET /api/moaops-docs/:saleId (liste justificatifs). Les écritures restent gardées par requireCoordinatorOrAdmin : PATCH /api/ventas/:id/status, leads intake/queue/search, /api/coordinateur/overview, goals, commissions, upload justificatif → tous 403 pour finance. FRONTEND (frontend/js/app.js) : isFinance() ; applyGating et navigate cantonnent finance au seul onglet ventes ; renderView route le ventes de finance vers renderVentesMoa ; bouton "+ Nouvelle vente" masqué ; openMvtModal force writable=false (fiche lecture seule, réutilise le mode supervision admin existant). Point 2 — prix tout inclus : sur le board renderVentesMoa (et la fiche openMvtModal), quand une unité a des annexes (cochera/baulera, regroupées côté moa-ops en r.annexes[] chacune avec son prixPaye), la colonne prix affiche désormais le total tout inclus = prixPaye appart + somme des annexes (helpers mvtTotalUsd/mvtHasAnnexes), avec un "incl. cochera/baulera" sous le montant ; l'en-tête colonne passe de "Prix payé" à "Prix total". La fiche de l'appartement gagne une section "Détail du prix" (appartement + chaque annexe + total). Même logique de total appliquée au listing secondaire coordVentas (onglet Coordination) et détail openVentaDetail (table ventas, champs cochera_precio_usd/baulera_precio_usd) pour cohérence. i18n vm.priceIncl/priceInclHint/priceBreakdown/priceTotal/apartment/annexe, vf.precio_total, coord.priceIncl/priceInclHint, vm.th.price→"Prix total" (ES/FR/EN). Compte Giannina créé via scripts/add_agent.mjs "Giannina" giannina@moa-realestate.com finance (rôle finance, title "Responsable financière", lien d'invitation must_change_password). Testé : node --check OK (server.js, app.js, i18n.js) ; bout en bout avec un JWT finance réel sur 127.0.0.1:8310 : GET /api/admin/moa-ventes = 200, POST /api/moaops/... = 403, PATCH /api/ventas/:id/status = 403, GET /api/coordinateur/overview = 403. moa-crm.service redémarré, /api/health 200. Fichiers frontend statiques (rechargement client suffit). Nommage : "Giannina"=finance, "Gia"=agente (voir §1).
  • - 2026-09-22 : Leads de source "VSL Immo" = français à 100% (certain) dans l'inférence de langue. Demande Matt : le funnel/lead magnet "VSL Immo" est 100% francophone, donc tous ses leads doivent afficher le drapeau français sans pourcentage (comme le champ language explicite). Ajout dans backend/langinfer.js d'une couche SOURCE_CERTAIN_LANGS (regex vsl\s*immo) + fromSourceCertain(lead) (teste source/tags/intake_source), branchée dans inferLanguages en étape 1bis avec CONF.explicit (100 → certain:true). Distinct de fromSource (indices de canal à ~55, ex. Ladislas=en) : ici la source GARANTIT la langue. Vérifié sur les 8 leads VSL réels de la base (source='VSL IMMO', dont un taggé YouTube+VSL) → tous {code:'fr',confidence:100,certain:true} ; cas Ladislas (en 55) et Catrinel (language explicite) inchangés. inferLanguages est calculé à la volée à chaque requête (pas de colonne persistée), moa-crm.service redémarré, is-active OK. Pour ajouter un autre canal mono-langue certain : une ligne dans SOURCE_CERTAIN_LANGS.
  • - 2026-09-22 (maj) : Source certaine = court-circuit total (une seule langue affichée). Matt : un lead VSL Immo doit afficher français uniquement, aucune autre langue. inferLanguages teste maintenant fromSourceCertain en tout premier (étape 0) et retourne directement [{code, confidence:100, certain:true}] sans évaluer les autres signaux (téléphone étranger, pays, message en anglais...) qui ajouteraient une 2e/3e langue. Vérifié : VSL avec tél US + message anglais → toujours [fr 100] seul ; leads non-VSL inchangés.
  • - 2026-09-22 : Leads saisis par le coordinateur (Gary) affichés "Organique - Gary" au lieu de la source brute. Demande Matt : un lead rentré à la main par Gary via l'intake coordinateur (endpoint /api/leads/intake, qui pose intake_source = coordinateur_manual|coordinateur_whatsapp) affichait sa source brute renseignée ("Whatsapp"...), ce qui ne dit rien d'utile. Désormais, dans les vues "Leads à assigner", la source affichée devient "Organique - Gary" pour tout lead dont intake_source commence par coordinateur_. Implémenté côté backend dans le shaping commun shapeUnassigned (server.js, ~ligne 1423) via un helper displaySource(l) : le remappage ne touche QUE l'affichage (la colonne source reste inchangée en base), et inferLanguages(l) tourne sur le lead brut AVANT le remappage (langue non affectée). Impacte aussi le filtre "Source" et la recherche des listes à assigner (construits sur la valeur retournée), qui montrent donc "Organique - Gary" de façon cohérente ; le formulaire d'édition de fiche continue d'afficher la vraie source stockée. 9 leads concernés au moment du changement (dont un à source vide, désormais étiqueté proprement). node --check OK, moa-crm.service redémarré, is-active OK. Fichier backend (pas statique) : redémarrage nécessaire, fait. NB : ne cible pas les leads intake_source='manual' (création via /api/leads par un agent/admin), seulement l'intake coordinateur.
  • - 2026-09-22 : Onglet "Mes prospects" pour Gary : historique de ce qu'il a saisi + correction directe. Demande Matt : sur la page Coordination où Gary rentre les prospects, lui donner en dessous l'historique des prospects qu'il a ajoutés, et pouvoir les modifier directement (corriger une faute de saisie : numéro, nom, email...). BACKEND : colonne leads.created_by (migration db.js, backfill idempotent depuis l'activité de création "Lead créé par le coordinateur" pour les 9 leads intake existants → tous rattachés à Gary) ; l'intake /api/leads/intake pose désormais created_by = acting.id. Deux routes (requireCoordinatorOrAdmin) : GET /api/leads/intake/mine (mes prospects, newest first, avec statut assigné/à assigner + étape) et PATCH /api/leads/intake/mine/:lid (édition bornée aux champs de contact/qualif INTAKE_EDIT_FIELDS, garde d'appartenance created_by, pas de réassignation/étape, whatsapp normalisé + miroir phone, activité de trace). Volontairement une route dédiée et scopée plutôt qu'élargir ensureLeadAccess/ownsLead (qui aurait ouvert delete/relance/catalog sur les leads non assignés). FRONTEND (app.js) : 3e onglet mine dans renderCoordinateur → coordMine(body) (table nom/WhatsApp/source/statut/date + bouton Modifier, recherche locale) + openIntakeEdit(lead, onSaved) (modale d'édition contact : nom complet, WhatsApp, email, langue, pays, projet, source, commentaire). i18n coord.tab.mine, coord.mine* (legend/searchPh/count/empty/edit/editTitle/saving/saved), intake.fullName (ES/FR/EN). Testé de bout en bout avec un JWT garyjeanlouis réel sur 127.0.0.1:8310 : GET intake/mine = 9 prospects (creator "Gary Jean-Louis") ; PATCH set residence_country+whatsapp (normalisé, phone miroir) OK puis revert ; PATCH d'un lead non créé par Gary = 403. node --check OK (server.js, db.js, app.js, api.js, i18n.js), moa-crm.service redémarré, /api/health 200. NB client : Gary doit recharger le CRM (SW network-first) pour récupérer le nouveau app.js/i18n.js ; l'onglet apparaît dans Coordination à côté de "Ventes" et "Leads à assigner".
  • - 2026-09-22 : Gary (coordinateur) ne voit plus QUE ses propres leads : retrait de l'onglet "Leads à assigner" et de la file/recherche globale. Demande Matt (« sur le crm moa, il faut que Gary voie uniquement les leads qu'il a rentré, et ensuite qu'il puisse modifier »). L'historique + édition existaient déjà (onglet "Mes prospects", cf. entrée du même jour) ; il restait l'onglet "Leads à assigner" (coordLeads) qui lui montrait la file de TOUS les leads non assignés + une recherche dans tout le CRM. FRONTEND (app.js renderCoordinateur) : pour un coordinateur pur (coordOnly(), Gary non-admin) l'onglet leads n'est plus rendu (liste de tabs = ventas + mine seulement), un état résiduel coordTab==='leads' retombe sur mine, et la tuile "leads à assigner" (compteur du pool global) est masquée. L'admin (Matt) garde tout (les 3 onglets + la tuile). BACKEND (server.js) : GET /api/leads/queue passe de requireCoordinatorOrAdmin à requireAdmin (la file globale n'est accessible qu'à l'admin, défense côté serveur au cas où l'API/MCP serait appelée hors UI). GET /api/leads/search reste requireCoordinatorOrAdmin : Gary en a besoin pour le contrôle de doublon EN DIRECT quand il crée un lead (il ne surface que des correspondances pour la personne qu'il saisit, pas une navigation). GET /api/leads/intake/mine + PATCH .../mine/:lid inchangés (ses leads + correction). Testé avec JWT réels sur 127.0.0.1:8310 : Gary GET /api/leads/queue → 403, admin → 200 ; Gary GET /api/leads/intake/mine → 200 (9 prospects, creator "Gary Jean-Louis") ; Gary GET /api/leads/search?q=loidts → 200 (dedup préservé). node --check OK (server.js, app.js), moa-crm.service redémarré, / 200. Cache-bust app.js?v=20260922garyleads, SW moa-crm-v71-20260922garyleads. NB client : Gary doit recharger le CRM (SW network-first) pour récupérer le nouveau app.js ; sa page Coordination n'affiche plus que "Ventes" et "Mes prospects".
  • - 2026-09-24 : Nouvel agent (Morgane Raoul) : compte + fiche RH + lien d'invitation envoyé par WhatsApp. Demande Matt ; source des infos = Sheet « Real Estate Agent info - MOA Real Estate (réponses) » (id 1cbNM3u62tzJM8BTHr5pyivNEpPgakW5phJiimKHpXNM, onglet « Réponses au formulaire 1 », ligne du 24/09/2026 ; formulaire RH rempli par chaque nouvel agent). Compte créé via node scripts/add_agent.mjs "Morgane Raoul" (depuis backend/) : login morganeraoul@moa-realestate.com, role='agent', id zJYVvREnm7GcC6cj, mot de passe neutralisé + lien d'invitation unique (30 j). Fiche RH remplie sur users (email perso morgane.rt@hotmail.com, tél +33767262775, passeport 24FH24556, née 26/11/1990, nationalité française, Calle Corochire / Residencia Fernando 1 / Fernando de la Mora, langues ES/EN/FR, contrat 24/09/2026). Exceptions feature_flags_user=1 posées sur les 8 vues agents (parité avec Monica Lucena, garde-fou si le défaut global changeait). Vérifié : GET /api/invite/<token> 200 (lien valide, non consommé) ; /api/me avec une session mintée sur son uid renvoie features 8/8 à true + wa_enabled:true, donc onglets visibles = Pipeline, Prospects, Tâches, Assistant, Biens, Promoteurs, Ventes (déclaration), WhatsApp ; onglets admin/coordination masqués (gardés par rôle). Lien envoyé sur son WhatsApp depuis l'instance Evolution de Matt (moacrm_ks5RdOfb3UIlhUWK, 4 messages courts en voix Matt, HTTP 201). Récaps mis à jour : /root/workspace/moa-crm-acces-agents.md (Invitation en attente) + /root/workspace/moa-crm-invitations.md (lien + date). Aucune modification de code, pas de redémarrage. Procédure réutilisable pour le prochain agent : lire la ligne du Sheet → add_agent.mjs → UPDATE users (champs RH) → overrides 8 vues → vérif /api/invite + /api/me → envoi WhatsApp → récaps.
  • - 2026-09-24 (après-midi) : Bug corrigé : aucun onglet visible juste après l'activation du lien d'invitation (Morgane). Demande Matt : « dans le CRM de Morgane, fais en sorte qu'elle ait accès à tous les onglets d'un agent normal ». Diagnostic : côté serveur elle avait déjà tout (resolveFeatures = 8/8, wa_enabled, parité exacte avec Anthony, un agent standard hors liste FULL_AGENT_EMAILS). Morgane a activé son compte vers 11h40 (invite consommée, wa_sessions créée à 11h44) et n'a vu QUE l'onglet WhatsApp + un écran « à venir ». Cause racine : POST /api/invite/:token (et POST /api/change-password, PATCH /api/me) renvoyaient publicUser(u) SANS features ; le front fait afterAuth(res.user) → state.me sans features → featureOn() faux partout → tous les onglets agents masqués, defaultView = Ventes → renderComingSoon. Un simple rechargement (/api/me) suffisait, mais l'agent ne le sait pas. Fix backend/server.js : nouveau helper meJson(u) = { ...publicUser(u), features: resolveFeatures(u) } utilisé par login, /api/me, GET /api/admin/user-view/:id, POST /api/change-password (x2 usages), PATCH /api/me et POST /api/invite/:token. node --check OK, moa-crm.service redémarré, /api/health 200 ; touch frontend/index.html pour bumper /api/version (les apps ouvertes, dont le téléphone de Morgane, se rechargent seules dès qu'elles sont inactives et récupèrent les onglets). Testé bout en bout avec un agent jetable (add_agent.mjs → GET /api/invite 200 → POST /api/invite 200 avec features 8/8 → POST /api/change-password 200 avec features 8/8 → /api/me 8/8), compte jetable supprimé ensuite (0 résidu). Rendu vérifié en headless Chromium (Playwright Python, --no-sandbox) avec une session mintée sur le compte de Morgane : nav = Pipeline, Prospects, Tâches, Assistant, Biens, Promoteurs, Ventes, WhatsApp (desktop et mobile, où la barre du bas montre 3 raccourcis + Menu), pas d'écran « à venir », vue d'accueil Ventes (normal hors FULL_AGENT_EMAILS, recherche globale masquée : idem Anthony/Maximo/Monica Lucena). Aucun changement de données pour Morgane. Récap /root/workspace/moa-crm-acces-agents.md resynchronisé sur la base (Morgane et Monica Lucena actives, Antonella archivée le 24/09, invitation de Thiago expirée depuis le 09/09). Sauvegarde du server.js avant fix : /root/aios/.tmp/moa-crm-server.js.bak-20260924.
  • - 2026-09-24 (suite) : Règle verrouillée : chaque nouvel agent a tout d'office, plus aucune étape manuelle. Demande Matt (« ça fait plusieurs fois qu'on crée des comptes et à chaque coup ils n'ont aucun accès à part WhatsApp, je dois tout refaire à la main »). Les trois causes historiques : (1) défauts globaux à 0 avant le 2026-09-15, (2) liste blanche FULL_AGENT_EMAILS à étendre à la main pour chaque agent (Pipeline/Prospects avant le 15/09, puis accueil + recherche), (3) le bug de la réponse d'invitation sans features, corrigé plus tôt dans la journée. Changements : backend/server.js : fullAgent(u) = admin OU role='agent' OU email de FULL_AGENT_EMAILS (défaut de la variable passé de Monica à vide ; la liste ne sert plus qu'aux autres rôles, Gary y reste). backend/scripts/add_agent.mjs : affiche ID=, puis pour un agent le contrôle ONGLETS=n/8 (...), WHATSAPP=on FULL_ACCESS=on et un avertissement si un onglet est coupé par le défaut global (aucune exception par agent écrite : la matrice admin reste la source de vérité). node --check OK, moa-crm.service redémarré, /api/health 200. Testé : agent jetable via add_agent.mjs → ONGLETS=8/8 → POST /api/invite 200 avec features 8/8 + full_access:true + wa_enabled:true (supprimé ensuite, 0 résidu) ; /api/me : Morgane, Anthony, Maximo, Monica Lucena passent full_access:true (8/8), Gary inchangé (liste), Valentina inchangée (0/8, rôle fixe) ; rendu headless avec la session de Morgane : accueil sur Pipeline, recherche globale visible, 8 onglets. Effet pour les agents déjà en place : Anthony, Maximo, Monica Lucena et Morgane arrivent maintenant sur leur Pipeline (au lieu de Ventes) avec la recherche, comme les autres. Procédure nouvel agent réduite à : add_agent.mjs → champs RH (UPDATE users) → lien WhatsApp → récaps.
  • - 2026-09-25 : POST /api/leads/ingest accepte une assignation directe (assignee_email + notify) → branchement des leads de pub Facebook sur l'agent. Demande Matt : la pub Facebook « Monica 50k » doit déverser ses leads dans le CRM directement sur Monica Ayala, avec l'alerte WhatsApp d'attribution à chaque nouveau lead. Jusqu'ici l'ingest créait toujours le lead en pool « à assigner » (assignee_id=NULL). Ajout dans backend/server.js (endpoint /api/leads/ingest) : champ optionnel assignee_email (résolu sur users, non archivé) → le lead est créé avec cet assignee_id, une activité « Attribué à X à l'ingestion » est tracée, logAssignment(...) enregistre le mouvement, et si notify n'est pas false l'alerte WhatsApp d'attribution part via notifyAssignment(lead, null, null) (même chemin que la création manuelle ligne ~1032). Rétro-compatible : sans assignee_email, comportement historique (pool à assigner, pas d'alerte). La réponse renvoie assigned_to + wa_alert. Testé bout en bout sur 127.0.0.1:8310 : POST avec assignee_email=monicaayala@moa-realestate.com + notify=false → lead créé, assignee_id = Monica Ayala, activité d'attribution présente, 0 activité WhatsApp (notify=false respecté) ; lead de test supprimé ensuite (0 résidu). node --check OK, moa-crm.service redémarré, /api/health 200. Côté AIOS, c'est le poller Meta Lead Ads (/root/aios/lib/meta_leads/poll.py, cron */3) qui appelle cet endpoint : nouvelle route « moacrm » pilotée par META_MOACRM_FORMS (form_id:email) dans /root/aios/.env, avec MOA_CRM_INGEST_URL + MOA_CRM_INGEST_KEY (= MOA_INGEST_API_KEY du CRM). Aujourd'hui : 3604539433045451:monicaayala@moa-realestate.com (form « Monica 50k », page Moa RealEstate). L'alerte WhatsApp part de la 1re session Evolution ouverte vers le numéro de l'agent (Monica liée dans team_members, +595995606450). Pour brancher une future pub sur un autre agent : ajouter form_id:email à META_MOACRM_FORMS.