Récupérer des données Odoo (GET) dans un client React qui utilise LiveKit 

Voici comment récupérer des données Odoo (GET) dans un client React qui utilise LiveKit — proprement et en sécurisé.

1) Architecture recommandée (sécurité & CORS)

Ne jetez pas votre clé API Odoo dans le navigateur.

Faites transiter les requêtes via votre backend :

React (LiveKit UI) └── appelle votre backend: GET /api/odoo/partners?phone=... └── backend appelle Odoo en JSON-RPC (clé API côté serveur) └── renvoie au front uniquement les champs utiles

Avantages : pas d’exposition de secrets, contrôle des champs, CORS simple.

2) Backend d’exemple (Node/Express) → Odoo JSON-RPC

Odoo expose une API JSON-RPC sur /web/dataset/call_kw.

Créez une clé API (Odoo > Préférences utilisateur > Clés API) et utilisez-la comme mot de passe pour l’utilisateur choisi.

// server.js import express from "express"; import axios from "axios"; const app = express(); app.use(express.json()); // Config Odoo (à mettre en variables d'environnement) const ODOO_URL = process.env.ODOO_URL; // ex: "https://votre-odoo.odoo.com" const ODOO_DB = process.env.ODOO_DB; // ex: "odoo-prod" const ODOO_USER = process.env.ODOO_USER; // ex: "vous@domaine.com" const ODOO_API_KEY = process.env.ODOO_API_KEY; // clé API générée dans Odoo // Helper JSON-RPC vers Odoo async function odooCall(model, method, args = [], kwargs = {}) { const payload = { jsonrpc: "2.0", method: "call", id: Date.now(), params: { model, method, args, kwargs, // Auth dans call_kw (depuis Odoo 16/17, préférer auth via header + /web/session/authenticate si besoin) // Ici on utilise l'auth via header Basic (user:apikey) directement côté axios. } }; const auth = Buffer.from(`${ODOO_USER}:${ODOO_API_KEY}`).toString("base64"); const { data } = await axios.post( `${ODOO_URL}/web/dataset/call_kw`, payload, { headers: { "Content-Type": "application/json", "Authorization": `Basic ${auth}` } } ); if (data.error) throw new Error(JSON.stringify(data.error)); return data.result; } // Endpoint REST de votre backend, filtré et safe app.get("/api/odoo/partners", async (req, res) => { try { const { phone, q } = req.query; // Domaine de recherche Odoo const domain = []; if (phone) domain.push(["phone", "ilike", phone]); if (q) domain.push(["name", "ilike", q]); const result = await odooCall( "res.partner", "search_read", [domain], { fields: ["id", "name", "phone", "mobile", "email"], limit: 25 } ); res.json(result); } catch (e) { res.status(500).json({ error: e.message }); } }); // Ex: récupérer l'utilisateur courant (utile pour lier l’identité LiveKit ↔ Odoo) app.get("/api/odoo/me", async (_req, res) => { try { const me = await odooCall("res.users", "search_read", [[["login", "=", ODOO_USER]]], { fields: ["id", "name", "login", "partner_id"] }); res.json(me?.[0] ?? null); } catch (e) { res.status(500).json({ error: e.message }); } }); app.listen(3001, () => console.log("API running on :3001"));

Variante : au lieu d’auth Basic, vous pouvez ouvrir une session via /web/session/authenticate puis réutiliser le cookie de session pour les appels suivants (utile si vous devez mimer un login).

3) Côté React (LiveKit + fetch de données Odoo)

Dans votre app React, vous :

  1. Récupérez les contacts Odoo via votre /api/odoo/partners.
  2. Affichez/filtrez ces contacts dans l’UI.
  3. Utilisez ces infos pour peupler la liste d’appelants, fiches client, etc., à côté de votre <LiveKitRoom>.

// ContactsPanel.tsx import { useEffect, useState } from "react"; type Partner = { id: number; name: string; phone?: string; mobile?: string; email?: string; }; export default function ContactsPanel() { const [contacts, setContacts] = useState<Partner[]>([]); const [q, setQ] = useState(""); useEffect(() => { const controller = new AbortController(); const run = async () => { const params = new URLSearchParams(); if (q) params.set("q", q); const res = await fetch(`/api/odoo/partners?${params.toString()}`, { signal: controller.signal, }); const data = await res.json(); setContacts(data); }; run(); return () => controller.abort(); }, [q]); return ( <div className="p-3 border rounded"> <input placeholder="Rechercher (nom, tel)" value={q} onChange={(e) => setQ(e.target.value)} className="border px-2 py-1 w-full mb-2" /> <ul className="space-y-2 max-h-64 overflow-auto"> {contacts.map(c => ( <li key={c.id} className="p-2 border rounded"> <div className="font-medium">{c.name}</div> <div className="text-sm opacity-80"> {c.phone || c.mobile ? `📞 ${c.phone ?? c.mobile}` : "—"} {c.email ? ` · ✉️ ${c.email}` : ""} </div> {/* Exemple : bouton pour initier une room LiveKit avec ce contact */} <button className="mt-2 px-2 py-1 border rounded"> Appeler (LiveKit) </button> </li> ))} </ul> </div> ); }

Et votre page LiveKit peut ressembler à :

// CallPage.tsx import { LiveKitRoom } from "@livekit/components-react"; import "@livekit/components-styles"; import ContactsPanel from "./ContactsPanel"; import { useEffect, useState } from "react"; export default function CallPage() { const [token, setToken] = useState<string | null>(null); // Récupérer un token LiveKit depuis VOTRE backend (/api/livekit/token) useEffect(() => { fetch("/api/livekit/token") .then((r) => r.json()) .then((d) => setToken(d.token)); }, []); if (!token) return <div>Initialisation…</div>; return ( <div className="grid grid-cols-3 gap-4 p-4"> <div className="col-span-1"> <ContactsPanel /> </div> <div className="col-span-2"> <LiveKitRoom serverUrl={import.meta.env.VITE_LIVEKIT_URL} token={token} connect={true} video={true} audio={true} > {/* … vos composants LiveKit (VideoConference, ParticipantTile, etc.) */} </LiveKitRoom> </div> </div> ); }

Le token LiveKit doit être généré côté serveur (signé avec votre API secret LiveKit). Même logique que pour Odoo : jamais de secret dans le front.

4) Domaines utiles (exemples Odoo)

  • res.partner → clients/contacts pour liste d’appel.
  • sale.order / account.move → contexte client pendant l’appel.
  • calendar.event → disponibilités / rendez-vous.
  • mail.activity → journaliser une activité “appel” à la fin.

Exemple pour journaliser une activité (backend) après un appel LiveKit :

await odooCall("mail.activity", "create", [ { res_model_id: false, // vous pouvez passer res_model + res_id à la place res_model: "res.partner", res_id: partnerId, activity_type_id: 4, // ex: "To Do" ou type personnalisé summary: "Appel LiveKit", note: "Durée: 12m34s – Agent: JHL – Outcome: Répondu", date_deadline: new Date().toISOString().slice(0,10), } ]);

(Adaptez activity_type_id selon votre base — ou créez un type “Appel”.)

5) Alternatives & variantes

  • Contrôleur Odoo REST : créer un module Odoo avec des routes /api/... qui renvoient exactement ce qu’il faut (auth par token Bearer).
  • GraphQL façade : votre backend peut exposer GraphQL vers le front et interroger Odoo en JSON-RPC en coulisse.
  • Filtrage RGPD : limitez strictement les champs renvoyés au front (pas d’adresses, etc., si non nécessaires).

TL;DR

  • Ne jamais appeler Odoo directement depuis React avec une clé API.
  • Passez par un backend proxy (Express/Nest/etc.) qui parle JSON-RPC à Odoo.
  • Dans React (à côté de votre LiveKitRoom), faites des fetch vers vos endpoints sécurisés pour lister contacts, contexte client, etc., et journalisez les appels en fin de session.

> EXEMPLE APPLICATIFS : 

1- GET data from ODOO contact 

Parfait — Odoo v17, on démarre par le modèle res.partner (Contacts).

Voici un mini-backend Express “copier-coller” + des endpoints propres (GET/LIST/DETAIL/CREATE/UPDATE, recherche par téléphone/e-mail), puis un hook React pour consommer tout ça côté LiveKit UI.

Backend (Node/Express) — Odoo v17 JSON-RPC

🛡️ Secrets en variables d’env. : ODOO_URL, ODOO_DB, ODOO_USER, ODOO_API_KEY

v17 accepte très bien l’auth Basic (login:api_key) sur /web/dataset/call_kw.

// server.js (Node 18+, "type": "module" dans package.json) import express from "express"; import axios from "axios"; const app = express(); app.use(express.json()); const ODOO_URL = process.env.ODOO_URL; // ex: https://votre-instance.odoo.com const ODOO_DB = process.env.ODOO_DB; // ex: "prod-db" const ODOO_USER = process.env.ODOO_USER; // ex: "vous@domaine.com" const ODOO_API_KEY = process.env.ODOO_API_KEY;// clé API Odoo (Preferences > Clés API) const AUTH = "Basic " + Buffer.from(`${ODOO_USER}:${ODOO_API_KEY}`).toString("base64"); async function odooCall(model, method, args = [], kwargs = {}) { const payload = { jsonrpc: "2.0", method: "call", id: Date.now(), params: { model, method, args, kwargs }, }; const { data } = await axios.post(`${ODOO_URL}/web/dataset/call_kw`, payload, { headers: { "Content-Type": "application/json", "Authorization": AUTH }, }); if (data.error) throw new Error(JSON.stringify(data.error)); return data.result; } // --- Champs standards utiles pour un softphone / fiche contact const PARTNER_FIELDS = [ "id","name","display_name","company_type","is_company","parent_id", "phone","mobile","email","website", "street","street2","city","state_id","zip","country_id", "vat","customer_rank","supplier_rank", "category_id","function","title","lang","tz", ]; // --- Helpers domaine function buildPartnerDomain({ q, phone, email, company, is_company }) { const domain = []; if (q) { domain.push("|","|", ["name","ilike",q], ["email","ilike",q], ["phone","ilike",q] ); } if (phone) domain.push("|",["phone","ilike",phone],["mobile","ilike",phone]); if (email) domain.push(["email","ilike",email]); if (company === "contacts") domain.push(["company_type","=","person"]); if (company === "companies") domain.push(["company_type","=","company"]); if (typeof is_company === "boolean") domain.push(["is_company","=",is_company]); return domain; } // --- LIST (avec pagination + tri) app.get("/api/odoo/partners", async (req, res) => { try { const { q, phone, email, company, is_company, limit = 25, offset = 0, order = "name asc" } = req.query; const domain = buildPartnerDomain({ q, phone, email, company, is_company: is_company === "true" ? true : is_company === "false" ? false : undefined }); const result = await odooCall("res.partner","search_read", [domain, PARTNER_FIELDS, parseInt(offset), parseInt(limit), order], // v17: args = [domain, fields, offset, limit, order] {} ); // Compte total (pour pagination) const total = await odooCall("res.partner","search_count",[domain],{}); res.json({ items: result, total, limit: Number(limit), offset: Number(offset) }); } catch (e) { res.status(500).json({ error: e.message }); } }); // --- DETAIL app.get("/api/odoo/partners/:id", async (req, res) => { try { const id = Number(req.params.id); const recs = await odooCall("res.partner","read",[[id], PARTNER_FIELDS],{}); res.json(recs?.[0] ?? null); } catch (e) { res.status(500).json({ error: e.message }); } }); // --- CREATE (minimal) app.post("/api/odoo/partners", async (req, res) => { try { const payload = req.body; // { name, email, phone, ... } const id = await odooCall("res.partner","create",[payload],{}); const rec = await odooCall("res.partner","read",[[id], PARTNER_FIELDS],{}); res.status(201).json(rec?.[0] ?? { id }); } catch (e) { res.status(500).json({ error: e.message }); } }); // --- UPDATE (PATCH) app.patch("/api/odoo/partners/:id", async (req, res) => { try { const id = Number(req.params.id); const vals = req.body; // { phone, mobile, email, ... } await odooCall("res.partner","write",[[id], vals],{}); const rec = await odooCall("res.partner","read",[[id], PARTNER_FIELDS],{}); res.json(rec?.[0] ?? { id }); } catch (e) { res.status(500).json({ error: e.message }); } }); // --- Recherche par numéro (ex: E.164) — phone OU mobile app.get("/api/odoo/partners/by-phone/:num", async (req, res) => { try { const num = req.params.num; const domain = ["|",["phone","ilike",num],["mobile","ilike",num]]; const result = await odooCall("res.partner","search_read",[ [domain], PARTNER_FIELDS, 0, 10, "write_date desc"],{}); res.json(result); } catch (e) { res.status(500).json({ error: e.message }); } }); // --- Recherche par email app.get("/api/odoo/partners/by-email/:email", async (req, res) => { try { const email = decodeURIComponent(req.params.email); const result = await odooCall("res.partner","search_read",[ [[["email","=",email]]], PARTNER_FIELDS, 0, 10, "write_date desc"],{}); res.json(result); } catch (e) { res.status(500).json({ error: e.message }); } }); app.listen(3001, () => console.log("API Odoo Contacts sur :3001"));

Notes importantes (v17)

  • Ordre des args dans search_read v17 (RPC “call_kw”) : [domain, fields, offset, limit, order].
  • company_type ∈ { "person", "company" } ; is_company est un booléen souvent redondant.
  • Les Many2one (ex. country_id) reviennent sous forme [id, "Name"].
  • Pour la normalisation téléphone (E.164), vous pouvez ajouter côté backend libphonenumber-js avant la requête.

Front (React) — Hook simple pour la liste des contacts

// usePartners.ts import { useEffect, useMemo, useState } from "react"; export type Partner = { id: number; name: string; display_name?: string; phone?: string; mobile?: string; email?: string; company_type?: "person"|"company"; is_company?: boolean; parent_id?: [number,string] | false; website?: string; street?: string; street2?: string; city?: string; zip?: string; state_id?: [number,string] | false; country_id?: [number,string] | false; vat?: string; customer_rank?: number; supplier_rank?: number; category_id?: [number,string][] | false; function?: string; title?: [number,string] | false; lang?: string; tz?: string; }; type ListResp = { items: Partner[]; total: number; limit: number; offset: number; }; export function usePartners(params: { q?: string; phone?: string; email?: string; company?: "contacts" | "companies"; is_company?: boolean; limit?: number; offset?: number; order?: string; }) { const [data, setData] = useState<ListResp>({ items: [], total: 0, limit: 25, offset: 0 }); const [loading, setLoading] = useState(false); const qs = useMemo(() => { const u = new URLSearchParams(); Object.entries(params).forEach(([k,v]) => { if (v !== undefined && v !== null && v !== "") u.set(k, String(v)); }); return u.toString(); }, [params]); useEffect(() => { let alive = true; setLoading(true); fetch(`/api/odoo/partners?${qs}`) .then(r => r.json()) .then(d => { if (alive) setData(d); }) .finally(() => alive && setLoading(false)); return () => { alive = false; }; }, [qs]); return { ...data, loading }; }

// ContactsPanel.tsx import { useState } from "react"; import { usePartners } from "./usePartners"; export default function ContactsPanel() { const [q, setQ] = useState(""); const { items, total, loading } = usePartners({ q, limit: 25, order: "name asc" }); return ( <div className="p-3 border rounded"> <div className="flex gap-2 mb-2"> <input value={q} onChange={e=>setQ(e.target.value)} placeholder="Recherche (nom/tél/email)" className="border px-2 py-1 flex-1" /> <span className="text-sm opacity-70">{loading ? "Chargement…" : `${total} résultats`}</span> </div> <ul className="space-y-2 max-h-80 overflow-auto"> {items.map(p => ( <li key={p.id} className="p-2 border rounded"> <div className="font-medium">{p.name}</div> <div className="text-sm opacity-80"> {p.phone || p.mobile ? `📞 ${p.phone ?? p.mobile}` : "—"} {p.email ? ` · ✉️ ${p.email}` : ""} </div> {/* Exemple: bouton "Appeler" -> initie/ouvre une room LiveKit */} <button className="mt-2 px-2 py-1 border rounded"> Appeler (LiveKit) </button> </li> ))} </ul> </div> ); }

Intégration LiveKit : gardez votre <ContactsPanel /> dans la colonne gauche de votre page d’appel à côté de <LiveKitRoom ...> (comme montré dans mon message précédent), et reliez le bouton « Appeler » à votre logique de création/joindre une salle + génération de token côté serveur.

Bonus : endpoints utiles pour la suite

  • Journal d’activité d’appel (créer une mail.activity liée au contact) quand l’appel se termine.
  • Rendez-vous (calendar.event) : proposer un créneau depuis l’UI LiveKit et créer l’event.
  • Appels inverses : endpoint qui, à partir d’un partner_id, renvoie les commandes ouvertes (sale.order), factures (account.move état = ouvert), etc., pour donner du contexte à l’agent pendant l’appel.


2- ’exemple de création d’événement calendar.event (option 2) pour planifier un rappel depuis l’UI (v17).

on ajoute l’exemple de création d’événement calendar.event (option 2) pour planifier un rappel depuis l’UI (v17).

Backend (Express) — créer un rendez-vous Odoo

Ajoutez ces routes à votre serveur (reprend le helper odooCall déjà fourni) :

// --- Champs utiles pour lecture d'événements const EVENT_FIELDS = [ "id","name","start","stop","allday","duration","location", "partner_ids","user_id","description","videocall_location" ]; // Créer un event : POST /api/odoo/events // body: { name, start, stop, allday, location, description, partner_ids, user_id, reminders } app.post("/api/odoo/events", async (req, res) => { try { const { name, start, // ISO: "2025-09-16T14:00:00" stop, // ISO: "2025-09-16T14:30:00" allday = false, location, description, partner_ids = [], // [partnerId1, partnerId2,...] user_id, // commercial/agent (Many2one res.users) optionnel reminders = [15], // minutes avant (ex: [15] => 1 rappel 15 min) videocall = false // si true, crée un lien meet Jitsi/Whereby selon vos modules; sinon ignorez } = req.body; // 1) créer l'event const eventId = await odooCall("calendar.event", "create", [{ name, start, stop, allday, location, description, partner_ids: partner_ids.length ? partner_ids.map(id => [4, id]) : [], user_id: user_id || false, videocall_location: videocall ? "event" : false, // selon vos addons/config }], {}); // 2) rappels (mail.activity.mixin n’est pas utilisé ici, on crée des alarmes) // Modèle des alarmes: calendar.alarm ; on relie via calendar.alarm_manager // Simplifié: utiliser "alarm_ids" via commands (si vos alarmes existent). // Variante: créer des alarmes dynamiques si nécessaire. if (Array.isArray(reminders) && reminders.length) { // Cherche une alarme "notification" minutes=XX, sinon en crée une const alarmIds = []; for (const mins of reminders) { const found = await odooCall("calendar.alarm","search",[[["type","=","notification"],["duration","=",mins],["interval","=","minutes"]]],{}); let alarmId = found?.[0]; if (!alarmId) { alarmId = await odooCall("calendar.alarm","create",[{ name: `Notif ${mins}min`, type: "notification", duration: mins, interval: "minutes" }],{}); } alarmIds.push(alarmId); } await odooCall("calendar.event","write",[[eventId],{ alarm_ids: alarmIds.map(id => [4,id]) }],{}); } const rec = await odooCall("calendar.event", "read", [[eventId], EVENT_FIELDS], {}); res.status(201).json(rec?.[0] ?? { id: eventId }); } catch (e) { res.status(500).json({ error: e.message }); } }); // Lister les events liés à un contact: GET /api/odoo/partners/:partnerId/events app.get("/api/odoo/partners/:partnerId/events", async (req, res) => { try { const partnerId = Number(req.params.partnerId); const { since, until, limit = 50, offset = 0, order = "start desc" } = req.query; const domain = [ ["partner_ids","in",[partnerId]], ]; if (since) domain.push(["start",">=", since]); // ISO date/time if (until) domain.push(["start","<", until]); const items = await odooCall("calendar.event","search_read", [domain, EVENT_FIELDS, parseInt(offset), parseInt(limit), order], {} ); const total = await odooCall("calendar.event","search_count",[domain],{}); res.json({ items, total, limit: Number(limit), offset: Number(offset) }); } catch (e) { res.status(500).json({ error: e.message }); } });

Remarque : si vous utilisez des liens de visioconférence spécifiques (Jitsi/Google Meet/Whereby via modules), adaptez le champ videocall_location selon votre config, ou stockez le lien dans location/description.

Front (React) — mini-formulaire “Planifier un rappel”

Un petit formulaire à placer à côté de votre UI LiveKit/Contact :

// ScheduleForm.tsx import { useState } from "react"; export default function ScheduleForm({ defaultPartnerId }: { defaultPartnerId: number }) { const [name, setName] = useState("Rappel client"); const [date, setDate] = useState(""); // "2025-09-16" const [start, setStart] = useState(""); // "14:00" const [duration, setDuration] = useState(30); // minutes const [location, setLocation] = useState(""); const [notes, setNotes] = useState(""); const [reminder, setReminder] = useState(15); const [saving, setSaving] = useState(false); const [ok, setOk] = useState<string | null>(null); const onSubmit = async (e: React.FormEvent) => { e.preventDefault(); setSaving(true); setOk(null); const startISO = new Date(`${date}T${start}:00`).toISOString(); const stopISO = new Date(new Date(startISO).getTime() + duration*60000).toISOString(); const res = await fetch("/api/odoo/events", { method: "POST", headers: { "Content-Type":"application/json" }, body: JSON.stringify({ name, start: startISO, stop: stopISO, location, description: notes, partner_ids: [defaultPartnerId], reminders: [reminder], videocall: false }) }); const data = await res.json(); setSaving(false); if (res.ok) setOk(`Événement créé (#${data.id})`); else setOk(`Erreur: ${data.error}`); }; return ( <form onSubmit={onSubmit} className="p-3 border rounded space-y-2"> <div className="font-medium">Planifier un rappel</div> <input className="border px-2 py-1 w-full" value={name} onChange={e=>setName(e.target.value)} placeholder="Titre" /> <div className="flex gap-2"> <input type="date" className="border px-2 py-1" value={date} onChange={e=>setDate(e.target.value)} /> <input type="time" className="border px-2 py-1" value={start} onChange={e=>setStart(e.target.value)} /> <input type="number" className="border px-2 py-1 w-24" value={duration} onChange={e=>setDuration(Number(e.target.value))} /> min </div> <input className="border px-2 py-1 w-full" value={location} onChange={e=>setLocation(e.target.value)} placeholder="Lieu (optionnel)" /> <textarea className="border px-2 py-1 w-full" rows={3} value={notes} onChange={e=>setNotes(e.target.value)} placeholder="Notes" /> <div className="flex items-center gap-2"> <span>Rappel:</span> <input type="number" className="border px-2 py-1 w-20" value={reminder} onChange={e=>setReminder(Number(e.target.value))} /> min avant </div> <button className="px-3 py-1 border rounded" disabled={saving}> {saving ? "Enregistrement…" : "Créer l’événement"} </button> {ok && <div className="text-sm opacity-80">{ok}</div>} </form> ); }

Intégration côté fiche contact / LiveKit

  • Récupérez partner_id depuis la carte contact (ex. sélection actuelle).
  • Affichez <ScheduleForm defaultPartnerId={partner.id} /> dans la colonne latérale.
  • Optionnel : après création, rafraîchir la liste GET /api/odoo/partners/:id/events pour montrer le rendez-vous.

À suivre ?

Je peux vous ajouter :

  • un endpoint “créneaux proposés” (ex. 3 créneaux J+1/J+2 avec fuseau Europe/Paris),
  • ou la journalisation d’appel mail.activity liée à l’événement créé.