Pro Hub — note d’intégration web & plan de tests
2026-08-21. Le backend du Pro Hub est déployé. Ce document s’adresse au développeur de
l’application web app.mikhmoai.com, qui porte désormais toute la partie technicien.
Répartition des rôles. Le Pro Hub — annuaire, progression, quota, supervision, portefeuille — se développe côté plateforme web, pas dans l’application mobile. Celle-ci reste concentrée sur le parcours client, pour ne pas l’alourdir. Les deux consomment la même API sur
live.jmoai.net; seules les surfaces diffèrent.
Le contrat de référence reste Contrat API — Pro Hub. Ici on teste.
1. À lire avant de coder — six points qui vont te surprendre
1. Les canaux temps réel ont changé de nom.
support_presence → support:presence et support_missions → support:missions.
Les deux-points ne sont pas cosmétiques : un canal sans eux tombe dans le namespace par défaut
de Centrifugo, sans présence, sans historique, sans join/leave. Abonne-toi aux noms avec
deux-points, sinon tout marchera « à moitié » sans erreur visible.
2. price: null veut dire « sur devis », pas « gratuit ».
Plusieurs techniciens ont répondu « Variable » à la question du tarif. On n’invente pas un
nombre à leur place. Affiche une mention, jamais 0.
3. status_detail vaut OFFLINE dès que isOnline est faux.
Un technicien hors ligne n’est ni disponible ni occupé : il est absent. N’affiche pas de pastille
verte sur la base du seul status_detail stocké.
4. La réponse est enveloppée. Toutes les routes rendent
{ "success": true, "data": …, "result": …, "domain": "backend" }. Lis data ; result
en est le miroir. Un success: false est une vraie erreur, pas un résultat vide.
5. La liste ne contient que des experts actifs ET certifiés. Si un technicien que tu connais n’apparaît pas, ce n’est pas un bug de pagination : il n’est pas certifié.
6. Les tags viennent d’une reprise volontairement prudente. Un tag n’a été posé que si le mot figurait littéralement dans l’expertise saisie. Les profils existants sont donc pauvres en tags tant qu’ils n’auront pas été complétés à la main. Un filtre qui ne rend rien peut être un profil incomplet, pas une régression.
2. Points d’entrée
Base : https://live.jmoai.net/api/v1
| # | Méthode | Chemin | Auth |
|---|---|---|---|
| 1 | GET | /support/professionals |
non |
| 2 | GET | /support/professionals/:id |
non |
| 3 | POST | /support/presence |
oui |
| 4 | GET | /support/centrifugo-token |
oui |
| 5 | POST | /support/attachments |
oui |
| 6 | POST | /support/wallet/tickets |
oui |
| 7 | POST | /support/tickets/:id/claim |
oui |
| 8 | POST | /support/tickets/:id/notes |
oui |
| 9 | GET | /support/tickets/:id/notes |
oui |
| 10 | POST | /support/tickets/:id/review |
oui |
3. Série de tests
A — Annuaire
| Test | Requête | Attendu | Ce que ça prouve |
|---|---|---|---|
| A1 | GET /support/professionals |
200, data[], meta{total,page,limit} |
La forme du contrat est respectée |
| A2 | ?tags=MikroTik |
Seuls les profils portant ce tag | Le filtre par tag opère sur un vrai tableau |
| A3 | ?tags=MikroTik,Fibre |
Profils ayant au moins un des deux | La correspondance est un OU, pas un ET |
| A4 | ?country=BJ |
Filtré ; ?country=bj donne le même résultat |
Code ISO, insensible à la casse |
| A5 | ?status=nimportequoi |
400 INVALID_STATUS |
Les valeurs libres sont rejetées, pas ignorées |
| A6 | ?limit=1&page=2 |
2ᵉ profil, meta.total inchangé |
La pagination est cohérente |
| A7 | ?status=online |
meta.total == nombre de lignes rendues |
Le total ne ment pas quand on filtre sur la présence |
| A8 | GET /support/professionals/<uuid-inexistant> |
404 PROFESSIONAL_NOT_FOUND |
Pas de 200 vide trompeur |
| A9 | GET /support/professionals/:id sur un profil réel |
bio, tier_level, votes, reviews[] en plus |
La fiche détaillée est bien enrichie |
B — Présence
| Test | Requête | Attendu |
|---|---|---|
| B1 | POST /support/presence {"online":true} |
200, data.isOnline = true |
| B2 | Aussitôt après : GET /support/professionals?status=online |
Le technicien y figure |
| B3 | Attendre 95 s sans battement, refaire B2 | Il n’y figure plus |
| B4 | POST /support/presence {"online":false} |
Disparaît immédiatement |
| B5 | Sans jeton | 401 |
B3 est le test important : le battement expire tout seul au bout de 90 s. Envoie-en un toutes les 60 s tant que l’écran technicien est au premier plan. Un technicien qui tue l’app doit disparaître de la liste sans rien faire de plus.
C — Temps réel
| Test | Action | Attendu |
|---|---|---|
| C1 | GET /support/centrifugo-token |
200 + token (JWT). Un 503 REALTIME_UNCONFIGURED signalerait un backend mal configuré — remonte-le |
| C2 | Connexion WebSocket avec ce jeton | Connexion acceptée |
| C3 | Jeton bricolé à la main | Connexion refusée |
| C4 | S’abonner à support:presence, puis faire B1 depuis un autre appareil |
Réception d’un PRO_STATUS_CHANGED |
| C5 | S’abonner à support:missions, puis créer un ticket rémunéré |
Réception d’un NEW_BOUNTY_TICKET |
| C6 | Deux techniciens abonnés, l’un prend le ticket | L’autre reçoit TICKET_CLAIMED |
Charges utiles exactes : voir le contrat. Elles sont vérifiées côté serveur et conservées dans l’historique du canal (24 h), donc reproductibles.
D — Missions rémunérées et séquestre
| Test | Requête | Attendu |
|---|---|---|
| D1 | POST /support/wallet/tickets avec un solde insuffisant |
400 SOLDE_INSUFFISANT + balance, required, missing |
| D2 | Idem avec un solde suffisant | 201, ticket en OPEN, somme bloquée |
| D3 | Vérifier le portefeuille après D2 | Le solde a bien diminué |
| D4 | POST /support/tickets/:id/claim |
200, ticket assigné |
| D5 | Refaire D4 depuis un second compte | 409 — le premier a verrouillé |
| D6 | Après D4, recharger l’annuaire | Le technicien passe BUSY si le ticket est urgent |
| D7 | Clôturer le ticket | Retour à AVAILABLE, ticketsResolved +1 |
| D8 | POST /support/tickets/:id/review {rating:4} |
200, la note moyenne du technicien bouge |
| D9 | review avec rating: 9 |
400 |
D5 est le test de la course. Lance les deux requêtes au même instant si tu peux : le verrou est en base, pas dans le code, donc il tient même en simultané.
D6 : le passage en
BUSYne concerne que les tickets urgents. Un technicien sur une demande de fond reste joignable pour une urgence — c’est voulu.
E — Notes internes
| Test | Requête | Attendu |
|---|---|---|
| E1 | POST /support/tickets/:id/notes en tant que technicien assigné |
201 |
| E2 | Idem depuis le compte client du ticket | 403 NOT_ASSIGNED |
| E3 | GET .../notes depuis le compte client |
403 |
| E4 | POST avec content vide |
400 EMPTY_NOTE |
| E5 | Sur un ticket inexistant | 404 TICKET_NOT_FOUND |
E2 et E3 sont les tests qui comptent : une note interne qui fuit vers le client n’a plus aucune raison d’exister.
F — Pièces jointes
| Test | Action | Attendu |
|---|---|---|
| F1 | POST /support/attachments {fileName, mimeType} |
200 + uploadUrl, objectKey, fileUrl |
| F2 | PUT du fichier sur uploadUrl |
200, sans en-tête d’authentification |
| F3 | Ouvrir fileUrl |
200, le fichier s’affiche |
| F4 | Envoyer un message avec attachment_url = objectKey |
201, l’écho contient un attachment_url ouvrable |
| F5 | GET /channels/:id/messages |
Chaque attachment_url est ouvrable |
| F6 | Réutiliser un uploadUrl après 15 min |
Refusé — expiré |
| F7 | Modifier un caractère de l’URL | Refusé — signature invalide |
| F8 | Ouvrir l’objet sans la partie signée (?X-Amz-…) |
403 — le bucket n’est pas public |
Ne mets pas
attachment_urlen cache longtemps. C’est un lien signé qui expire au bout de 15 minutes, resigné à chaque lecture de la conversation. Il n’est pas stable, et c’est volontaire : le bucket reste privé, aucun lien ne survit à sa fuite. Pour réafficher une pièce jointe plus tard, recharge les messages.Ce que tu renvoies dans
attachment_urlà l’envoi doit être l’objectKey, pas le lien signé.
4. Spécificités du client WEB — à lire en premier
Un navigateur est soumis à deux contrôles qu’une application native ne subit pas. C’est là que se perdent les premières heures.
1. L’origine est vérifiée au WebSocket. Centrifugo compare l’en-tête Origin à une liste
blanche. https://app.mikhmoai.com y est déclaré et vérifié — une origine absente reçoit
un 403 dès la poignée de main, avant tout message, et le symptôme (« connexion
impossible ») ne dit rien de la cause.
⚠️ Conséquence directe pour ton poste de développement : un serveur local
(http://localhost:5173, 127.0.0.1:3000…) sera refusé. Demande-nous d’ajouter ton
origine de développement — c’est une ligne de configuration côté serveur, tu ne peux pas la
contourner depuis le navigateur.
Mesuré :
| Origine | Résultat |
|---|---|
https://app.mikhmoai.com |
✅ accepté |
https://app.jmoai.net |
✅ accepté |
| une origine non déclarée | ❌ 403 au handshake |
| aucune (app native) | ✅ accepté — non concernée |
2. Le CORS de l’API. https://app.mikhmoai.com figure déjà dans les origines autorisées
du backend. Rien à faire, mais si tu sers depuis un autre nom, dis-le-nous.
3. L’authentification accepte deux formes. Un jeton Authorization: Bearer … ou la
session par cookie. Le web peut utiliser l’une ou l’autre — mais si tu prends le cookie, pense
à credentials: 'include' sur tes fetch, sinon le navigateur ne l’enverra pas et tu
recevras un 401 TOKEN_MISSING en te croyant connecté.
5. Adresses et état du déploiement
| Usage | URL |
|---|---|
| API REST | https://live.jmoai.net/api/v1 |
| WebSocket temps réel | wss://chat.jmoai.net/connection/websocket |
| Pièces jointes | https://chat.jmoai.net/expert-hub-media/… (URL rendue par l’API) |
Le TLS est en place depuis le 2026-08-21 : domaine dédié chat.jmoai.net, certificat
Let’s Encrypt à renouvellement automatique. Vérifié de bout en bout — envoi pré-signé accepté,
relecture conforme, et accès sans signature refusé en 403.
Ce domaine est volontairement séparé de vpn.mikhmoai.com, qui porte WebFig et le VPN. Sa
racine répond 404 : ni la console MinIO ni l’API d’administration de Centrifugo n’y sont
exposées.
⚠️ Mets l’URL en configuration, pas en dur. Elle a déjà changé une fois aujourd’hui, et un jour on voudra un domaine par environnement.
Les profils sont pauvres. Deux techniciens en base, un seul certifié, zéro mission terminée, aucun tarif chiffré. Prévois l’affichage des cas vides : pas d’avis, pas de tarif, pas de tag.
6. Contrat d’erreur — formes réelles, relevées en production
Toute erreur REST sort sous cette forme. error.code est le seul champ à tester ; le
message est susceptible de changer.
{ "success": false, "error": { "code": "…", "message": "…" }, "domain": "backend" }
Réponses réellement capturées :
| Situation | HTTP | error.code |
|---|---|---|
?status= invalide |
400 | INVALID_STATUS |
| Profil inconnu | 404 | PROFESSIONAL_NOT_FOUND |
| Aucun jeton | 401 | TOKEN_MISSING |
| Jeton illisible / mauvaise signature | 401 | TOKEN_INVALID |
| Jeton expiré | 401 | TOKEN_EXPIRED |
| Note sur un ticket non assigné | 403 | NOT_ASSIGNED |
| Note vide | 400 | EMPTY_NOTE |
| Quota de récupération dépassé | 429 | RECOVERY_RATE_LIMIT |
| Temps réel non configuré | 503 | REALTIME_UNCONFIGURED |
| Solde insuffisant | 400 | SOLDE_INSUFFISANT (+ balance, required, missing) |
| Ticket déjà pris | 409 | RACE_CONDITION |
TOKEN_EXPIREDetTOKEN_INVALIDsont distincts depuis le 2026-08-21. Avant, les deux sortaient enSERVER_ERRORet l’app ne pouvait pas les séparer d’une panne. Le premier se résout en rafraîchissant la session ; le second exige une reconnexion complète. Un champmessageest conservé à la racine pour compatibilité — ne t’appuie pas dessus.
7. Centrifugo — comportements mesurés
Testés en connexion réelle sur wss://chat.jmoai.net/connection/websocket :
| Cas | Ce que fait Centrifugo |
|---|---|
| Jeton valide | Connexion acceptée, la réponse connect porte ttl: 3599 |
| Jeton expiré | Erreur applicative code: 109 « token expired » — la connexion n’est PAS fermée |
| Signature invalide | Fermeture, code de déconnexion 3500 |
| Aucun jeton | Fermeture, code de déconnexion 3501 |
Ce qu’il faut en faire :
- Le
ttlrenvoyé à la connexion est le mécanisme prévu : branche le callbackgetTokendu SDK surGET /v1/support/centrifugo-token. Le SDK rafraîchit avant l’expiration, la connexion n’est jamais coupée, et il n’y a rien à écouter sur les canaux. - Le 109 est récupérable : redemande un jeton et rejoue. Ne déconnecte pas l’utilisateur.
- 3500 et 3501 sont des fermetures définitives : il faut une vraie ré-authentification.
- Les canaux
support:presenceetsupport:missionsne portent aucun événement d’erreur : l’authentification se joue entièrement à la connexion.
Le jeton vit 1 heure.
8. Si un test échoue
Donne-nous la requête complète, le code HTTP et le corps de la réponse. Les codes d’erreur
sont explicites (INVALID_STATUS, NOT_ASSIGNED, SOLDE_INSUFFISANT, RACE_CONDITION,
REALTIME_UNCONFIGURED) : c’est le code, pas le message, qui identifie la cause.
Un 503 REALTIME_UNCONFIGURED et un 429 RECOVERY_RATE_LIMIT ne sont pas des bugs de
l’app : ce sont des états serveur à nous signaler tels quels.