← Tous les manuels ·
PortailMANUAL — rp-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-11 · Statut : brouillon auto (à relire)
1. Identité
- Rôle : CRM prospects unifié de Resident Paraguay, pour Younes. Centralise toutes les sources de leads RP dans un pipeline unique (dédup + suivi de source). Pipeline à 9 étapes, fiche prospect avec notes/prochaine action/qualification, vue « À faire ».- URL publique : https://crm-resident.panelbay.com (login équipe, mot de passe TEAM_PASSWORD dans .env)- Entité : RP- Port : 8332 (127.0.0.1)
2. Exécution / infra
- Dossier : /root/workspace/apps/rp-crm- Stack : Node.js, Python, HTML/statique- Service systemd : rp-crm.service (systemctl restart rp-crm.service)- Port : à préciser- Reverse proxy (nginx) : à préciser- Redémarrer : systemctl restart rp-crm.service
3. Structure & fichiers clés
- backend/server.js- backend/db.js- frontend/index.html- backend/package.json
4. Données
- Base / stockage : - data/rp-crm.db- Modèle / tables : TODO — tables / structure des données
5. API / endpoints
- GET /api/meta- GET /api/prospects- GET /api/prospects/:id- GET /api/session- GET /api/tasks- GET /health- POST /api/ingest- POST /api/login- POST /api/prospects- POST /api/prospects/:id/move- POST /api/prospects/:id/next- POST /api/prospects/:id/next/done- POST /api/prospects/:id/note- POST /api/prospects/:id/qualify — champs de qualification {objectif, echeance, budget} (valeurs = clés des options dans QUALIFIERS, db.js)- POST /api/prospects/:id/archive — {archived:0|1} archive/désarchive (alimente le filtre « Archivés »)- POST /api/prospects/:id/tag — {tag:"froid"|null} marque froid/chaud (alimente le filtre « Froids »)- GET /api/duplicates — groupes de doublons candidats (fiches côte à côte + keeper suggéré)- GET /api/duplicates/count — juste le compteur (pastille d'alerte, polling 60s)- POST /api/duplicates/merge — {keeper_id, loser_id, signal} fusionne loser dans keeper (réversible : loser archivé, jamais supprimé)- POST /api/duplicates/dismiss — {id_a, id_b, signal} marque « pas un doublon » (paire ignorée définitivement)- GET /api/wa/status — état de la connexion WhatsApp (connecting/open/close) + numéro- POST /api/wa/connect — crée l'instance + pose le webhook + renvoie le QR d'appairage (base64)- POST /api/wa/logout — déconnecte le WhatsApp (sans supprimer l'instance)- POST /api/wa/webhook/:instance?token=SECRET — public (secret en query), réception des messages Evolution- GET /api/wa/inbox — conversations groupées par numéro (+ compteur « à trier »)- GET /api/wa/thread?number= — fil d'une conversation (marque lu)- GET /api/wa/prospect/:id — conversation WhatsApp d'un prospect (rattachement par numéro via samePhone)- POST /api/wa/create-contact — {number, name} crée un prospect (source whatsapp) depuis un numéro inconnu + rattache le fil- POST /api/wa/link — {number, prospect_id} rattache un fil à un prospect existant- POST /api/wa/send — {number, text} envoie un message (Younes répond depuis le CRM)- POST /api/logout — révoque la session serveur et efface le cookie- POST /api/prospects/:id/push-moa — transfert croisé vers le CRM MOA Real Estate (bouton « ↗ Envoyer au CRM MOA » de la fiche). Pousse une copie du prospect (nom/email/tél + contexte source/commentaires) vers l'ingest MOA (MOA_CRM_INGEST_URL, clé MOA_CRM_INGEST_KEY dans .env = MOA_INGEST_API_KEY côté moa-crm). Le lead atterrit côté MOA non assigné (assignee_id NULL, stage sans_contact, source resident_paraguay, intake crm_rp), donc dans le bloc « à assigner » du dashboard de Matt. Idempotent : l'ingest MOA déduplique (email > tél), recliquer enrichit sans doubler (deduped:true). Trace une activité + pose moa_pushed_at. Miroir exact du bouton MOA→RP (/api/leads/:lid/push-rp).
6. Intégrations & secrets
- Récap matinal email (jobs/morning_recap.py) : digest quotidien du CRM envoyé par email à Younes. Déclenché par le timer systemd rp-crm-recap.timer (07:30 heure Asunción, tous les jours) → service rp-crm-recap.service. Envoi via Gmail (lib/google_workspace.py, scope gmail.send), expéditeur/destinataire dans jobs/recap_config.json (défaut : resident.paraguay@gmail.com). Test : python3 jobs/morning_recap.py --dry-run (aperçu HTML dans jobs/_recap_preview.html) ou --to <email> pour un envoi de test. Logs : journalctl -u rp-crm-recap.- Ingestion sources : POST /api/ingest (x-api-key), connecteurs dans sources/ (gmail_poller.py). Voir SPEC.md.- Secrets / clés (indiquer OÙ elles sont, JAMAIS leur valeur) : .env (PORT, INGEST_API_KEY, TEAM_PASSWORD). Token Gmail : /root/aios/data/.creds/google_accounts/resident.paraguay@gmail.com.json.
6bis. WhatsApp (Evolution API)
- Instance dédiée rpcrm_younes sur l'Evolution partagée (127.0.0.1:8080, conteneur Docker evolution-api), isolée des instances des autres CRM (moacrm_*, mpcrm_*). Module client : backend/wa.js (adapté de moa-crm). Vars dans .env : EVOLUTION_API_URL, EVOLUTION_API_KEY, WA_INSTANCE, WA_WEBHOOK_SECRET, WA_PUBLIC_BASE.- Connexion : Younes ouvre « 💬 WhatsApp » dans le CRM → « Générer le QR » → scanne avec son téléphone (WhatsApp → Appareils connectés). Une fois open, les messages arrivent.- Webhook : Evolution POST les messages (MESSAGES_UPSERT, entrants ET sortants via fromMe) sur WA_PUBLIC_BASE/api/wa/webhook/rpcrm_younes?token=SECRET. On passe par l'URL PUBLIQUE (pas 127.0.0.1:8332) parce qu'Evolution tourne dans un conteneur Docker : son 127.0.0.1 n'est pas l'hôte. Le webhook est enregistré côté Evolution (/webhook/set/).- Rattachement : chaque message est relié au prospect par numéro via samePhone (rapprochement strict, ≥9 chiffres communs, repris de moa-crm). Un numéro inconnu reste « à trier » ; Younes clique « Créer le contact » (popup) → nouveau prospect source whatsapp + rattachement rétroactif des messages. Sert à constituer sa base de contacts (leads + autres).- Historique : capté à partir de la connexion (limite du protocole WhatsApp Web : pas d'historique rétroactif complet à l'appairage).- Tables : wa_messages (dédup sur wa_message_id UNIQUE), wa_session (état). Source de prospect whatsapp ajoutée.
7. Pièges connus / à savoir
- WhatsApp webhook = URL publique obligatoire (Evolution est dans Docker, 127.0.0.1 y désigne le conteneur, pas l'hôte). Ne jamais pointer le webhook sur 127.0.0.1:8332.- Le QR d'appairage expire vite : si le scan échoue, régénérer (« Générer le QR » relance /api/wa/connect).- samePhone refuse les rapprochements < 9 chiffres communs (anti faux positif) : un prospect sans numéro complet ne sera pas relié automatiquement.- L'INSERT OR IGNORE sur wa_message_id UNIQUE évite les doublons quand Evolution relivre un webhook.
8. CHANGELOG
- 2026-09-11 : Bouton « ↗ Envoyer au CRM MOA » dans la fiche prospect (transfert croisé RP → MOA, miroir de MOA → RP). Demande de Matt : depuis le CRM de Younes, renvoyer un prospect vers le CRM MOA Real Estate pour qu'il arrive directement dans ses « contacts à assigner ». BACKEND (server.js) : config MOA_CRM_INGEST_URL / MOA_CRM_INGEST_KEY (près de INGEST_API_KEY) ; nouvel endpoint POST /api/prospects/:id/push-moa (protégé par la session équipe) qui POST vers l'ingest MOA (/api/leads/ingest, header x-api-key) avec source:'resident_paraguay', intake_source:'crm_rp', note de contexte (étape RP, source, commentaires) + raw.origin='rp_crm'. Le lead atterrit côté MOA non assigné (assignee_id NULL, stage sans_contact) donc dans le bloc « à assigner » du dashboard de Matt. Idempotent (l'ingest MOA déduplique email > tél : recliquer renvoie deduped:true et enrichit sans doubler). Trace une activité + pose la colonne moa_pushed_at. DB (db.js) : migration ALTER TABLE prospects ADD COLUMN moa_pushed_at. FRONT (index.html) : bouton #moab dans le pied de fiche (.pc-foot), état « ✓ Envoyé au CRM MOA » si déjà poussé, confirmation avant envoi, gestion d'erreur (le helper api() ne throw pas sur 4xx/5xx → on teste r.ok). .env (racine, chargé par systemd) : MOA_CRM_INGEST_URL + MOA_CRM_INGEST_KEY (= MOA_INGEST_API_KEY côté moa-crm). SW rpcrm-v16 → rpcrm-v17-20260911pushmoa (auto-reload client via /api/version sur mtimes). node --check server+db OK, service redémarré, testé bout en bout (prospect factice créé → push → lead MOA créé non assigné, source resident_paraguay ; 2e push → deduped:true ; test nettoyé des deux bases). Note : c'est l'exact symétrique du bouton MOA → RP (moa-crm /api/leads/:lid/push-rp) déjà en place depuis 2026-08-21.- 2026-08-22 : Migration en masse de 382 leads « intérêt résidence » depuis le CRM MOA (script one-shot). Demande Matt : transférer à Younes les leads MOA non assignés potentiellement intéressés par la résidence, avec (1) une vraie date d'origine pour prioriser les récents et (2) sans doublon. Sur 531 leads MOA tagués résidence : 148 déjà présents ici (skippés via dedup email/tél), 382 créés (source=moa_crm), 1 dédupliqué/enrichi par ingest(). Vraies dates récupérées hors CRM (la date Odoo des leads était un import de masse du 29/07/2026, inexploitable) en croisant par email/tél 3 sources RP : Airtable Prospects (Date Heure Creation) + Airtable Affiliate Leads (Creation) + la feuille Drive « Recontact Leads RP avec MOA ». 306 fiches ont reçu leur vraie date via un UPDATE prospects.created_at post-ingest() (l'ingest estampille sinon à la date du jour) ; les 76 sans date retrouvée ont été mises à une sentinelle 2023-01-01 pour tomber en FIN de file de récence (elles ne doivent pas passer devant les leads réellement récents), leur note portant « date d'origine inconnue ». Chaque fiche porte une note de contexte complète (catégorie cross-sell vs origine résidence, date d'origine + source, langue, origine MOA, étape MOA, réf MOA/Odoo, puis les notes MOA intégrales). Côté MOA : rp_pushed_at posé + activité de transfert sur les 383 (anti-re-push). Sauvegarde data/rp-crm.db.bak-20260822-preMoaResiMigration avant écriture. Prospects RP 746 -> 1128. Export de contrôle : workspace/MOA-leads/leads-residence-migration-2026-08-22.csv.- 2026-08-21 : Nouvelle source moa_crm (transfert croisé depuis le CRM MOA Real Estate). Ajout de { key: 'moa_crm', label: 'MOA Real Estate (transfert croisé)' } à SOURCES (backend/db.js). Contexte : le CRM MOA a un bouton « Envoyer à Resident Paraguay » sur sa fiche client qui POST vers /api/ingest d'ici (header x-api-key = INGEST_API_KEY, cf. .env) avec source:'moa_crm'. Le cœur ingest() déduplique déjà (email > tél > nom) donc un renvoi n'ajoute pas de doublon (enrichit la fiche). Les leads transférés arrivent en status:'nouveau', avec une note « Transféré depuis le CRM MOA par <user> le <date> » + les notes MOA, et raw.origin='moa_crm' (+ moa_lead_id/odoo_lead_id). Service rp-crm redémarré, node --check db.js OK, testé de bout en bout (prospect créé source=moa_crm puis dédupliqué au 2e envoi, test nettoyé). Aucun changement d'UI ni d'auth ici : c'est une porte d'entrée serveur-à-serveur en localhost.- 2026-08-20 (auto-refresh) : Auto-refresh sur nouvelle version déployée, sans déconnexion (parité avec moa-crm). BACKEND (server.js) : GET /api/version (public, ajouté à la liste blanche du middleware d'auth /api ligne ~161) = max des mtimes de index.html/sw.js ; import fs ajouté. FRONTEND (JS inline dans index.html) : setupVersionWatch() appelé en fin de boot() (donc seulement une fois authentifié), sonde /api/version toutes les 90 s ; à changement, recharge si inactif (isSafeToReload : aucune dialog[open], aucun champ focus), sinon bannière « Nouvelle version disponible · Recharger » (stylée inline) + reload auto dès que libre. Session par cookie (rpcrm_auth) + admin en sessionStorage → un reload garde tout, aucune déconnexion. SW network-first + /api/* non caché → le reload récupère le nouvel index.html. Cache SW rpcrm-v15 → rpcrm-v16-20260820ver. node --check server + JS inline extrait OK, service redémarré, /api/version répond (8332), boot sans erreur JS. Concerne Younes (accès équipe).- 2026-08-20 (wa) : NOUVEAU : intégration WhatsApp (Evolution API) pour archiver les conversations de Younes (demande Matt). Instance dédiée rpcrm_younes sur l'Evolution partagée, connexion par QR depuis le CRM (bouton « 💬 WhatsApp »). Module backend/wa.js (client Evolution, adapté de moa-crm : ensureInstance/connect/state/fetchInstance/sendText/logout/setWebhook/parseInbound). Webhook MESSAGES_UPSERT (entrants + sortants) sur l'URL PUBLIQUE (Evolution = Docker, cf. §7). Stockage wa_messages + wa_session (db.js), source prospect whatsapp. Rattachement au prospect par numéro (samePhone strict). Numéro inconnu = « à trier » → popup « Créer le contact » (nouveau prospect + relink rétroactif) pour bâtir la base de contacts. Réponse possible depuis le fil (/api/wa/send). Front : bouton + pastille « à trier », modale connexion QR / boîte de conversations / fil avec bulles + zone de réponse. Endpoints /api/wa/* (voir §5). SW rpcrm-v14 → rpcrm-v15-20260820wa. Testé : QR généré, webhook enregistré côté Evolution, parsing + rattachement au bon prospect + création de contact validés (transaction annulée, zéro pollution). Reste : Younes scanne le QR pour passer open et lancer la capture. Service redémarré.- 2026-08-20 : NOUVEAU : gestion des doublons avec validation manuelle par Younes (demande Matt). Détection + fusion, aucune fusion automatique (choix explicite : certains cas sont ambigus, ex. deux prénoms différents partageant un téléphone, un email pro vs un gmail, donc c'est Younes qui tranche). Nouveau module backend backend/dupes.js : table dupe_resolutions (pair_key unique = ids triés, action dismissed/merged, snapshot JSON de la fiche absorbée pour réversibilité) ; détection par union-find sur 3 signaux (même phone_norm, même nom folded ≥2 mots, même email_norm), exclut les paires déjà résolues ; keeper suggéré = étape la plus avancée puis plus d'historique puis plus ancien. Fusion : enrichit le keeper sans écraser ses données par du vide, déplace les activités du loser vers le keeper, archive le loser (jamais supprimé, dedup_key libéré) et logge une activité merge + une résolution. Endpoints : /api/duplicates, /api/duplicates/count, /api/duplicates/merge, /api/duplicates/dismiss (voir §5). Front (frontend/index.html) : bouton ⚠️ Doublons + pastille dans le header (masqué si 0, polling 60s pour une alerte quasi temps réel), modale dlgDupes qui affiche chaque groupe (fiches côte à côte, choix du keeper au clic, bouton « Fusionner » / « Pas un doublon »). SW rpcrm-v13 → rpcrm-v14-20260820dupes. Audit à la mise en place : 9 groupes / 10 fiches en trop (surtout des doublons Ladislas import + emails pro vs perso). Testé bout-en-bout (détection, fusion, enrichissement, archivage, non-réapparition) sur une paire factice en transaction annulée (zéro vraie fiche touchée). Service rp-crm redémarré. La dédup à l'ingestion (email/phone) reste en place ; ce module rattrape les cas que l'ingestion laisse passer (même personne, emails différents).- 2026-08-13 : Splash de reconnexion (fin du flash du login au démarrage). L'app se reconnecte par cookie de session ; le temps de revalider (GET /api/session au boot()), le board vide puis, si la session avait expiré, la fenêtre de login pouvaient clignoter. Ajout d'un overlay plein écran #boot-splash (marque « Resident Paraguay » + sous-titre « CRM », spinner, message « Connexion en cours… ») affiché par défaut au chargement. Palette RP : fond vert sombre en dégradé (#0f3325 → #0a231a), spinner et sous-titre en vert accent (var(--accent) = #1f9d57), texte clair (#eef4f0). Le login étant une <dialog> rendue dynamiquement (pas de markup statique à cacher), le splash s'affiche par défaut et c'est le JS qui le retire. Fonction hideSplash() appelée sur les DEUX chemins de résolution de session : succès (fin de boot(), après render()) et session expirée / 401 (dans showLogin(), qui couvre aussi le cas 401 via api()). Filet de sécurité setTimeout(hideSplash, 4000) près de l'appel boot() pour ne jamais bloquer l'UI derrière un spinner permanent. Respect de prefers-reduced-motion (spinner ralenti). Front only : frontend/index.html (HTML splash après <body>, CSS #boot-splash, JS hideSplash + appels). Cache-bust : pas de schéma ?v= dans cette app (fichier unique, CSS/JS inline), le shell est versionné par le service worker : SW rpcrm-v12 → rpcrm-v13-20260813splash. Pas de restart (statique). Vérifié : node --check OK sur sw.js et sur le JS inline extrait de index.html.- 2026-08-06 (soir 6) : Téléphone en aperçu sur les cartes du board. Avant, la carte n'affichait que l'email OU le téléphone (email prioritaire). Désormais l'email et le numéro de téléphone (ligne dédiée « ☎ <numéro> ») s'affichent tous deux en aperçu sur la carte pipeline, chacun conditionnel à sa présence. phone était déjà servi par LIST_COLS (aucun changement backend). Front only : frontend/index.html (cardHtml : 2 lignes .m conditionnelles ; CSS .card .m.tel = pas de coupure de mot). SW rpcrm-v11 → rpcrm-v12. Pas de restart. Testé navigateur (cartes réelles affichent le n° sous l'email ; carte sans email affiche le n° seul).- 2026-08-06 (soir 5) : Icône « copier » le numéro de téléphone. Même principe que l'email : quand le prospect a un téléphone, un petit bouton icône ⧉ (.copybtn.copyico, id #copyPhone) apparaît juste après le numéro dans .pc-contact ; clic → copie via copyToClipboard + état « ✓ Copié ». Front only : frontend/index.html (bouton conditionnel + binding #copyPhone, CSS .copyico compact), SW rpcrm-v10 → rpcrm-v11. Pas de restart. Testé navigateur (bouton rendu après le numéro).- 2026-08-06 (soir 4) : Bouton « Copier » l'email dans la fiche prospect. Demande de Matt (copie facile de l'email). Dans l'en-tête de la fiche (.pc-contact), quand le prospect a un email, un petit bouton « ⧉ Copier » apparaît juste après l'adresse ; au clic il copie l'email dans le presse-papiers (navigator.clipboard.writeText, fallback execCommand('copy') via textarea caché) et affiche « ✓ Copié » (vert) pendant 1,4 s. Front only : frontend/index.html (helper global copyToClipboard(text,btn) près de esc(), bouton conditionnel dans openCard, binding #copyEmail juste après showModal, CSS .copybtn/.copybtn.ok), SW rpcrm-v9 → rpcrm-v10. Pas de restart (statique). Testé navigateur : helper présent, bouton rendu à côté de l'email, clic → état « ✓ Copié ».- 2026-08-06 (soir 3) : Bouton « contacté » : auto-passage Nouveau→Contacté + annulation (toggle), vert = jour calendaire. Demande de Matt. (1) Auto-passage : marquer « contacté » un prospect en colonne nouveau le déplace automatiquement en contacte (backend POST /api/prospects/:id/contacted : si status==='nouveau' → status='contacte', renvoie status ; activité status « Déplacé en Contacté (auto) » journalisée). Vaut aussi pour l'enregistrement d'une date de contact. Depuis toute autre colonne : pas de déplacement. (2) Annulation : re-cliquer le bouton quand il est déjà « contacté aujourd'hui » annule (efface last_contact) ; nouveau chemin POST .../contacted {cancel:true} qui met last_contact=NULL et ne change JAMAIS la colonne (activité « Contacté annulé »). Front markContacted() devient un toggle basé sur contactedToday(). (3) Vert = jour calendaire : nouvel helper contactedToday(iso) (comparaison année/mois/jour) remplace daysSince(...)===0 (fenêtre 24h) pour l'état vert du bouton (carte + fiche) et le libellé « aujourd'hui » de la fiche ; le lendemain le bouton repasse neutre. Bouton fiche #ctbtn et bouton carte .qa-contact deviennent état-aware (libellé « ↺ Annuler le contacté » quand actif). Fichiers : backend/server.js (endpoint étendu), frontend/index.html (contactedToday, markContacted toggle, boutons, lcsave applique le nouveau statut). SW rpcrm-v8 → rpcrm-v9. node --check OK, service redémarré, test bout-en-bout (marquer nouveau→contacte + date OK ; annuler → last_contact effacé, statut reste contacte ; prospect de test restauré).- 2026-08-06 (soir 2) : Re-segmentation depuis l'Airtable RP (source de vérité) + nouvelle colonne « À vérif par Matt ». Demande de Matt : le seed initial avait versé les 605 prospects de l'Airtable RP (table Prospects, base appGzbfkhHiO95jd1) tous en nouveau/lead_magnet, noyant les ~50 leads réellement à traiter de Younes. Correctif basé sur le champ Airtable « Archivé » (fldPjAyqANtGTIhjD, description officielle : *anciens leads avant de reprendre suivi avec Younes ; les new leads RP sont non cochés et apparaissent dans son Kanban*). (1) Nouveau stage a_verif_matt (label « À vérif par Matt »), position en fin de board. (2) Matching Airtable↔CRM par email_norm/téléphone (mêmes normalisations que ingest.js) : les 605 prospects Airtable retrouvés (538 uniques après dédup). (3) 125 leads actifs (Archivé=faux) : statut restauré depuis « Etape Pipeline » (Pas de reponse→pas_reponse, Nouveau Prospect/vide→nouveau, RDV Pris→rdv, Proposition→proposition, Contact Perdu→perdu). (4) 480 anciens leads (Archivé=vrai) → a_verif_matt + tag='ancien lead (à vérif)', sauf ceux déjà gagne/contacte/avancés côté CRM qui ont été préservés (37 clients gagnés + 1 contacté restaurés après coup : on n'enterre pas un client gagné même s'il est coché archivé dans l'Airtable). (5) Imports récents intacts : ladislas (184), direct (5), 1 lead_magnet hors Airtable. Répartition finale : a_verif_matt 382, ladislas_contacte 171, pas_reponse 73, nouveau 59 (les ~50 de Younes retrouvés), gagne 37, contacte 3, perdu/proposition/rdv 1. Migration Python one-shot (supprimée après run), stages + prospects.status/tag modifiés en direct (WAL). Sauvegarde propre : data/rp-crm.db.snapshot-2026-08-06-postreseg (VACUUM INTO, WAL intégré ; le cp simple ne capture pas le WAL). Aucun changement de code applicatif ; /api/meta sert la colonne dès connexion.- 2026-08-06 (soir) : Fiche prospect, 4 ajustements (demande Matt). (1) Vraie timeline de statut dans la fiche : le stepper de pastilles (.stepper/.step) est remplacé par une frise horizontale reliée (.tl > .node avec .dotc + .lbl), coche pour les étapes passées, numéro pour les suivantes, étape courante surlignée (anneau vert). Toujours cliquable (setStage). Scroll horizontal si beaucoup d'étapes. Fiche élargie : dialog.pcard de 560 à 720px. (2) Date de dernier contact éditable : la section « Dernier contact » a un input[type=date] (#lcdate, prérempli, plafonné à aujourd'hui) + bouton « Enregistrer la date », en plus du bouton « ✓ Aujourd'hui ». Backend : POST /api/prospects/:id/contacted accepte désormais un body optionnel {date:"YYYY-MM-DD"} (validé, posé à 12:00, activité journalisée) ; sans body = maintenant (comportement inchangé du bouton 1 clic). (3) Croix de fermeture (.x, en haut à droite) sur toutes les fenêtres (fiche, prochaine action, à faire, nouveau prospect, mot de passe, colonnes, installation) ; Échap ferme aussi (natif <dialog>). Login non concerné (garde d'accès). (4) Renommer/déplacer les colonnes : fonctionnalité déjà en place (bouton header « ⚙️ Colonnes » + code admin ADMIN_CODE de backend/.env ; ✏️ renomme, entêtes draggables réordonnent). Vérifié de bout en bout ce jour (verify bon/mauvais code, reorder, rename : tous 200). Aucun changement, juste confirmé fonctionnel. Backend : server.js (/contacted étendu, node --check OK, service redémarré). Front : frontend/index.html (CSS .tl/.x, openCard timeline + section contact + CLOSEX, helper isoToDateInput), SW rpcrm-v7 -> rpcrm-v8. Vérifié navigateur (fiche 720px, timeline 10 nœuds, date préremplie, croix). Donnée de test (last_contact d'un prospect) restaurée après coup.- 2026-08-06 : Refonte UI (thème clair) + 8 évolutions produit. Demande de Matt. (1) Contact en 1 clic : bouton « Contacté aujourd'hui » (fiche) + « contacté » (carte) : POST /api/prospects/:id/contacted pose last_contact=now (colonne ajoutée dans db.js) et journalise une activité contact. (2) Relance requise : une carte sans next_action dont le dernier contact (ou la création si jamais contacté) date de plus de 3 jours passe en bordure rouge + badge « Relance requise » (relanceRequired()). Exclut gagne/perdu/ladislas_contacte (sinon les 171 Ladislas parkés viraient tous au rouge). (3) Next step facile depuis le board (bouton par carte : dlgTask) ET la fiche. (4) Déplacement instantané : le drag ne re-render plus tout le board, il déplace le nœud DOM tout de suite (map LEADS + refreshBoardCard()), l'API /move suit en arrière-plan (revert par render() si échec). Idem timeline de statut et actions rapides. (5) Commentaires : colonne comment + POST /api/prospects/:id/comment + textarea persistant dans la fiche. (6) « Marquer froid » retiré : bouton, chip, filtre « Froids » et vue cold supprimés ; /api/prospects ne filtre plus par tag (tous les prospects visibles). L'endpoint /tag reste (inoffensif). (7) Statut = timeline cliquable : la liste déroulante + bouton « Déplacer » remplacée par un stepper de pastilles cliquables (.stepper/.step, done/cur/todo) ; clic = setStage. (8) Refonte visuelle : thème vert sombre remplacé par un thème clair lisible (fond gris-vert clair, colonnes/cartes blanches, ombres douces, accent vert RP, chips source colorées, focus visibles). Backend : db.js (colonnes last_contact, comment), server.js (LIST_COLS+/api/tasks exposent les colonnes, endpoints contacted/comment, requête board sans exclusion froid). Front : frontend/index.html réécrit. SW rpcrm-v6 puis rpcrm-v7. node --check OK, self-test backend 10/10, rp-crm.service redémarré (727 prospects), vérifié navigateur (thème clair, bordures « Relance requise », timeline cliquable, bouton contacté, commentaires). RESTE (point 9, à valider avant mise en service) : filtre d'import email des pays à visa (Inde/Pakistan/Afrique) : réponse auto + non-ingestion. Le poller ne lit aujourd'hui que les métadonnées (pas de pays) : détection à ajouter (classifieur IA sur le corps), liste de pays + envoi d'emails auto à confirmer par Matt.- 2026-08-06 — Board éditable (colonnes dynamiques) + drag & drop + suppression + refonte fiche. Cinq évolutions demandées par Matt. (1) Colonnes en base, plus en dur : nouvelle table stages(key,label,position,system) dans backend/db.js, seedée une fois depuis l'ancien tableau STAGES (devenu simple graine). getStages()/stageKeys() remplacent les constantes statiques ; /api/meta et /health et la validation de /move lisent la base. Colonnes system (nouveau, gagne, perdu) non supprimables. (2) Gestion des colonnes réservée à l'admin : le CRM n'a qu'un mot de passe d'équipe partagé (pas de rôles), donc j'ai ajouté un code admin séparé (ADMIN_CODE dans backend/.env, override possible via setting admin_code). Endpoints gardés par en-tête x-admin-code : POST /api/stages (créer, slug auto unique), PATCH /api/stages/:key (renommer le label, key stable donc prospect.status reste valide), POST /api/stages/reorder (permutation complète des positions), DELETE /api/stages/:key (interdit sur system ; réassigne les prospects de la colonne vers into, défaut nouveau, avant suppression). GET /api/admin/verify pour déverrouiller le front. Front : bouton header « ⚙️ Colonnes » qui demande le code (dialog dlgAdmin), stocke le code en sessionStorage, pose la classe body.admin-on révélant sur chaque entête ✏️ (renommer, prompt) et 🗑 (supprimer, sur non-system), le header devient draggable (réordonnancement), et une colonne fantôme « + Ajouter une colonne ». (3) Drag & drop des cartes entre colonnes : cartes draggable, chaque .col est zone de drop (indice visuel .drop-hint), le drop appelle /move. Usage courant, ouvert à tous les connectés (pas admin). (4) Réordonnancement des colonnes en drag : entêtes draggables en mode admin, drop → /stages/reorder. (5) Supprimer un prospect : DELETE /api/prospects/:id (supprime aussi ses activités), bouton rouge « 🗑 Supprimer » dans la fiche avec confirmation. (6) Refonte de la fiche (openCard) : en-tête nom + contact + chips (source/statut/date), puis sections encadrées lisibles (« D'où vient ce lead », « Statut », « Prochaine action », « Qualification », « Notes », « Historique »), pied avec Froid/Archiver/Supprimer/Fermer ; dialog élargi (dialog.pcard), showModal protégé (if(!dlg.open)). SW rpcrm-v5 → rpcrm-v6. node --check OK ; self-test backend 15/15 (login via session injectée car mot de passe d'équipe non connu du .env backend) ; vérif navigateur (login, fiche refondue, mode admin : ✏️/🗑 par colonne, « Nouveau » sans 🗑). Code admin communiqué à Matt (modifiable sur demande). Fichiers : backend/db.js, backend/server.js, backend/.env, frontend/index.html, frontend/sw.js. NB infra : le service charge EnvironmentFile=/root/workspace/apps/rp-crm/.env (TEAM_PASSWORD y vit) ET dotenv lit backend/.env (ADMIN_CODE) ; les deux coexistent.- 2026-08-06 — Colonne "Ladislas (déjà contactés)" + tri automatique. Demande de Matt : sortir du pipeline les leads Ladislas déjà contactés pour les traiter plus tard. (1) Nouveau stage ladislas_contacte (label « Ladislas (déjà contactés) ») ajouté à STAGES dans backend/db.js, inséré juste après nouveau. La colonne apparaît automatiquement (board 100% dynamique depuis META.stages) et STAGE_KEYS la rend valide pour /api/prospects/:id/move. (2) Tri source de vérité = boite RP. Script one-shot (supprimé après run) : pour chacun des 184 leads source='ladislas', requête Gmail in:sent to:<email> sur resident.paraguay@gmail.com (via lib/google_workspace + googleapiclient, lecture seule). Résultat : 171 déjà contactés (un mail leur est bien parti), 13 jamais contactés. (3) Migration (backup data/rp-crm.db.bak-ladislas-2026-08-06) : les 171 passés en ladislas_contacte avec activité status journalisée (« mail déjà envoyé depuis la boite RP, tri auto ») ; les 13 restent en nouveau. (4) Front : ladislas_contacte ajouté à l'exclusion needsStep() (pas de rappel « définir le next step » sur cette colonne parking). SW rpcrm-v4 → rpcrm-v5. node --check OK, rp-crm.service redémarré, /health confirme le stage. Rappel (constat du 2026-08-06) : le poller Gmail n'importe que l'entrant, ne trace pas l'outbound, d'où l'affichage trompeur « nouveau » sur des leads en réalité contactés.- 2026-08-06 — Mot de passe d'équipe modifiable + bouton "Installer l'app" (PWA bureau). (1) Changement de mot de passe : le mot de passe partagé n'est plus figé dans .env. Table settings(key,value) (créée au boot dans backend/server.js) ; hash scrypt (scrypt:salt:hash) stocké sous la clé team_password. verifyPassword() : si un hash existe en base on le vérifie, sinon repli sur TEAM_PASSWORD du .env (aucune régression). Nouvel endpoint POST /api/change-password (authentifié, rate-limité) : vérifie l'actuel, exige ≥6 caractères, enregistre le nouveau hash et invalide toutes les autres sessions (garde la session courante). Front : bouton header « 🔑 Mot de passe » → dlgPass (actuel + nouveau + confirmation, contrôles côté client). (2) Installation bureau : bouton header « ⬇ Installer l'app » (#install) ; utilise l'invite native beforeinstallprompt (Chrome/Edge) quand dispo, sinon ouvre dlgInstall avec les instructions selon l'OS/navigateur (iOS Safari « Sur l'écran d'accueil », Safari Mac « Ajouter au Dock », Firefox → utiliser Chrome/Edge). Masqué si déjà en mode standalone ; se masque après appinstalled. Le manifest + sw + icônes existaient déjà, il manquait le déclencheur visible. SW rpcrm-v3 → rpcrm-v4. node --check OK, service redémarré, testé : login OK (mot de passe .env inchangé), change-password 401 sans auth / 400 si actuel faux ; boutons vérifiés au navigateur. Fichiers : backend/server.js, frontend/index.html, frontend/sw.js.- 2026-08-06 — Récap « D'où vient ce lead » sur chaque fiche. Demande de Matt (avant point avec Younes) : afficher sur chaque fiche prospect son origine + le contexte disponible. Implémenté côté serveur, calculé à la volée (pas de colonne à maintenir, s'applique à tous les leads existants et futurs) : helper buildRecap(p) dans backend/server.js qui combine (a) un libellé d'origine par source (SOURCE_ORIGIN : lead_magnet / meta_ads / ladislas / direct / systeme_io / guillermo / manual) et (b) le contexte extrait du payload brut raw quand il existe (subject, snippet tronqué à 200, lang — présents pour les leads issus du poller Gmail : Ladislas et contacts directs), plus la date de réception. Exposé via un champ recap ajouté à la réponse de GET /api/prospects/:id. Front (frontend/index.html, openCard) : nouveau bloc « D'où vient ce lead » (bordure accent, white-space:pre-line) rendu sous la ligne source, conditionnel à p.recap. Pour les lead magnet sans message, message explicite « à contacter pour qualifier ». SW rpcrm-v2 → rpcrm-v3. node --check OK, rp-crm.service redémarré, vérifié au navigateur (fiche Ladislas : origine + objet du mail + date affichés). Aucune donnée modifiée en base. État leads au moment du point : 345 actifs, 297 en attente (224 nouveau dont 179 Ladislas + 42 lead magnet + 3 directs ; 73 pas de réponse). Brief : workspace/CRM-Younes-Point-Leads-2026-08-06.md.- 2026-08-05 — Durcissement sécurité + corrections d'audit (P1/P2). Suite à l'audit Audit-apps/rp-crm.md. Sécurité (P1) : (1) sessions révocables : le jeton d'auth n'est plus une constante sha256(password) mais un jeton aléatoire par login, stocké en table sessions(token, created_at, expires_at) (migration auto backend/db.js), avec expiration serveur (90 j) et POST /api/logout (bouton « Quitter » dans l'entête). (2) cookie rpcrm_auth désormais Secure (en plus de HttpOnly/SameSite=Lax). (3) rate-limit sur /api/login (express-rate-limit, 10/min/IP) + comparaison du mot de passe en temps constant (crypto.timingSafeEqual). (4) secrets : .env reste la source (PORT/INGEST_API_KEY/TEAM_PASSWORD), fallbacks codés en dur laissés en place volontairement (pas de fail-fast pour ne pas risquer le démarrage) ; rotation des secrets déléguée à l'orchestrateur (Audit-apps/_secret_rotation_rp-crm.tsv). En-têtes (P2) : helmet() (CSP désactivée car frontend à scripts inline), x-powered-by désactivé, cors() retiré (app same-origin). Fondations/Perf (P2) : bouton « Creer » désactivé pendant l'envoi + dédup sur nom normalisé quand ni email ni téléphone (fin des doublons au double-clic, backend/ingest.js) ; GET /api/prospects n'renvoie plus la colonne raw et accepte limit/offset (défaut 2000, plafond 5000) ; GET /api/tasks plafonné à 5000. UX (P2) : nouveaux endpoints POST /api/prospects/:id/archive et .../tag + boutons « Archiver » / « Marquer froid » dans la fiche (les filtres « Archivés » / « Froids » sont enfin alimentables) ; skeleton de chargement + état d'erreur avec bouton « Réessayer » dans render(). Accessibilité (P2) : cartes, chips « next step » et lignes de tâches transformées en vrais contrôles clavier (role=button + tabindex=0 + gestion Entrée/Espace), board enveloppé dans <main>, focus visible. SW passé en rpcrm-v2 pour purger l'ancien shell. Deps ajoutées : helmet, express-rate-limit. Sauvegarde DB : data/rp-crm.db.bak-2026-08-05. node --check OK sur server/db/ingest, rp-crm.service redémarré, healthcheck OK (727 prospects). Items P3 (#11 labels, #12 UI optimiste, #13 mobile-first, #14 validation longueur ingestion) non traités (hors périmètre P1/P2). Reste orchestrateur : rotation des secrets, éventuel durcissement CSP nginx.- 2026-08-05 — Champs de qualification + récap matinal email pour Younes. (1) Qualification : 3 champs adaptés à la résidence Paraguay ajoutés à la fiche prospect (menus, sauvegarde auto, historisée en activité qualif) : Objectif (fiscalité / plan B / s'installer / business / retraite / autre), Échéance (immédiat → long terme / indécis), Budget (prêt / à financer / limité / inconnu). Modèle : QUALIFIERS + colonnes qual_objectif/qual_echeance/qual_budget (migration auto, backend/db.js) ; exposé dans /api/meta ; endpoint POST /api/prospects/:id/qualify (backend/server.js) ; UI bloc « Qualification » dans openCard (frontend/index.html). (2) Récap matinal : jobs/morning_recap.py envoie chaque matin (07:30 Asunción, timer systemd rp-crm-recap.timer) un digest par email à resident.paraguay@gmail.com : actions en retard + du jour, nouveaux leads à contacter, leads sans prochaine action, snapshot pipeline, activité des dernières 24h. Sujet préfixé [CRM Younes]. Destinataires : jobs/recap_config.json. Restart rp-crm.service fait.- 2026-08-02 — Manuel initialisé (bootstrap auto).
CHANGELOG
- 2026-08-14 — Email immédiat à chaque demande du formulaire de contact du site. Avant, une soumission du formulaire (POST /api/web-lead) n'était qu'enregistrée dans le CRM (source site_web) ; rien ne partait par email sur le coup (seulement le récap matinal 07:30). Désormais, chaque demande déclenche AUSSI un email immédiat vers resident.paraguay@gmail.com (destinataires dans jobs/recap_config.json) avec le détail complet (nom, email cliquable, téléphone + lien WhatsApp, projet, message, date Asunción) et un Reply-To = email du prospect (répondre à l'email écrit directement au prospect). Nouveau script jobs/notify_lead.py : lit le lead en JSON sur stdin, envoie via le même chemin Gmail que morning_recap.py (lib/google_workspace, scope gmail.send, expéditeur resident.paraguay@gmail.com). Câblage backend : backend/server.js importe spawn, helper notifyLeadEmail(payload) qui lance le script détaché, fire-and-forget (stdin JSON pour ne jamais mal interpréter le texte utilisateur ; toute erreur d'envoi est avalée et ne casse ni la réponse HTTP ni le formulaire), appelé juste après ingest() dans /api/web-lead. node --check OK, python3 -c ast.parse OK, rp-crm.service redémarré. Testé : envoi isolé (email reçu, id retourné) + POST /api/web-lead bout-en-bout (HTTP 200 {ok:true}, lead créé source=site_web puis supprimé). Le lead continue d'atterrir dans le CRM comme avant ; l'email est un ajout, pas un remplacement.- 2026-08-12 — Endpoint public POST /api/web-lead pour le formulaire du site RP. Le site statique ne peut pas porter la clé d'ingestion (fuite en clair côté navigateur) : nouvel endpoint sans api-key, CORS restreint aux origines du site (resident-paraguay.fr, www, residentparaguay.panelbay.com, resident.bouthors-m.tenga.run), honeypot company (réponse 200 sans écrire si rempli), rate-limit 6/min/IP, validation + troncature des champs, puis appel du même ingest() avec source:'site_web' (nouvelle source ajoutée à db.js) et inbound_reply:true. Ajout au gate public (/web-lead), préflight OPTIONS. node --check OK (server.js, db.js), rp-crm.service redémarré, testé de bout en bout (préflight 204 + Allow-Origin ; POST -> lead créé source=site_web ; honeypot -> pas de création ; origine non autorisée -> aucun header CORS). Lead de test supprimé après coup.- 2026-08-25 : service tombé (arrêt propre en masse à 18h37 le 24/08, non rattrapé par Restart=on-failure). Relancé + policy passée à Restart=always pour éviter la rechute.- 2026-09-02 — Barre de recherche (nom/email/téléphone) dans l'entête du board. Demande de Younes (via Matt). Champ #fsearch ajouté dans le header, entre le badge WhatsApp et le filtre source. Filtre en pur client-side : GET /api/prospects renvoie déjà tous les prospects actifs/archivés en une fois (jusqu'à 2000, LIST_COLS sans raw), donc pas de round-trip serveur par frappe. render() refactorisé : le fetch alimente LASTLIST (liste brute), une nouvelle fonction drawBoard(list) porte le rendu des colonnes (extrait tel quel de l'ancien render), et applySearchAndDraw() filtre LASTLIST sur le texte tapé (name/email/phone, insensible à la casse) avant d'appeler drawBoard. oninput sur #fsearch redessine instantanément sans re-fetch. Aucun changement backend. node --check sur le JS extrait OK. Pas de redémarrage rp-crm.service nécessaire (fichier statique servi par express.static, /api/version se recalcule sur les mtimes et déclenchera l'auto-reload des onglets ouverts).