Skip to main content

qefro_backend_sdk/
customer_hub.rs

1//! Optional Customer Hub client — mirrors `@qefro-ai/backend` 1.7.0.
2//!
3//! Hub participation is gated by `QEFRO_CUSTOMER_HUB_ENABLED` (default false) and
4//! `QEFRO_CUSTOMER_HUB_OPTIONAL` (default true). When disabled/optional, hub
5//! methods soft-skip (`None` / no-op) instead of failing the tool.
6
7use anyhow::{anyhow, Result};
8use serde::{Deserialize, Serialize};
9use serde_json::{json, Map, Value};
10use std::sync::Arc;
11use tokio::sync::Mutex;
12
13/// Customer Hub gateway context injected on `tool.invoke` (`platform.customer`).
14#[derive(Debug, Clone, Serialize, Deserialize, Default)]
15pub struct PlatformCustomerContext {
16    pub tenant_id: String,
17    pub workspace_id: String,
18    #[serde(default, skip_serializing_if = "Option::is_none")]
19    pub installation_id: Option<String>,
20    #[serde(default, skip_serializing_if = "Option::is_none")]
21    pub solution_id: Option<String>,
22    #[serde(default, skip_serializing_if = "Option::is_none")]
23    pub identity_id: Option<String>,
24    #[serde(default, skip_serializing_if = "Option::is_none")]
25    pub conversation_id: Option<String>,
26    #[serde(default, skip_serializing_if = "Option::is_none")]
27    pub person_id: Option<String>,
28    #[serde(default, skip_serializing_if = "Option::is_none")]
29    pub capabilities: Option<Vec<String>>,
30    #[serde(default, skip_serializing_if = "Option::is_none")]
31    pub source: Option<String>,
32}
33
34/// Managed storage context (ADR-002) — kept for platform parity; Hub is independent.
35#[derive(Debug, Clone, Serialize, Deserialize, Default)]
36pub struct PlatformStorageContext {
37    pub tenant_id: String,
38    pub workspace_id: String,
39    pub installation_id: String,
40    pub solution_id: String,
41    #[serde(default, skip_serializing_if = "Option::is_none")]
42    pub identity_id: Option<String>,
43    #[serde(default)]
44    pub capabilities: Vec<String>,
45    #[serde(default, skip_serializing_if = "Option::is_none")]
46    pub source: Option<String>,
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize, Default)]
50pub struct PlatformStorageBinding {
51    #[serde(default, skip_serializing_if = "Option::is_none")]
52    pub base_url: Option<String>,
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub token: Option<String>,
55    #[serde(default, skip_serializing_if = "Option::is_none")]
56    pub context: Option<PlatformStorageContext>,
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize, Default)]
60pub struct PlatformCustomerBinding {
61    #[serde(default, skip_serializing_if = "Option::is_none")]
62    pub base_url: Option<String>,
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub token: Option<String>,
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    pub context: Option<PlatformCustomerContext>,
67}
68
69/// Platform capabilities injected on `tool.invoke`.
70#[derive(Debug, Clone, Serialize, Deserialize, Default)]
71pub struct PlatformCapabilities {
72    #[serde(default, skip_serializing_if = "Option::is_none")]
73    pub storage: Option<PlatformStorageBinding>,
74    /// Optional Customer Hub binding (`QEFRO_CUSTOMER_HUB_ENABLED`).
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    pub customer: Option<PlatformCustomerBinding>,
77}
78
79#[derive(Debug, Default)]
80pub struct CustomerState {
81    pub current: Option<Value>,
82    pub lookup_completed: bool,
83}
84
85pub fn env_flag_true(name: &str, default: bool) -> bool {
86    match std::env::var(name) {
87        Ok(raw) if !raw.is_empty() => match raw.trim().to_ascii_lowercase().as_str() {
88            "1" | "true" | "yes" | "on" => true,
89            "0" | "false" | "no" | "off" => false,
90            _ => default,
91        },
92        _ => default,
93    }
94}
95
96/// Master switch — when false, hub methods soft-skip (never call Hub).
97pub fn is_customer_hub_enabled() -> bool {
98    env_flag_true("QEFRO_CUSTOMER_HUB_ENABLED", false)
99}
100
101/// When true (default), missing hub config returns `None` / no-ops.
102pub fn is_customer_hub_optional() -> bool {
103    env_flag_true("QEFRO_CUSTOMER_HUB_OPTIONAL", true)
104}
105
106pub fn read_identity_phone(identity: &Value) -> Option<String> {
107    let obj = identity.as_object()?;
108    for key in ["phone", "phone_number", "whatsapp_number", "whatsapp"] {
109        if let Some(Value::String(s)) = obj.get(key) {
110            let trimmed = s.trim();
111            if !trimmed.is_empty() {
112                return Some(trimmed.to_string());
113            }
114        }
115    }
116    None
117}
118
119/// Map a Person / Hub JSON object to the canonical HubCustomer projection.
120pub fn hub_customer_from_person(person: Option<&Value>) -> Option<Value> {
121    let person = person?.as_object()?;
122    let id = person.get("id")?.as_str()?.trim();
123    if id.is_empty() {
124        return None;
125    }
126
127    let phone = person
128        .get("phone_number")
129        .and_then(|v| v.as_str())
130        .filter(|s| !s.is_empty())
131        .or_else(|| {
132            person
133                .get("phone")
134                .and_then(|v| v.as_str())
135                .filter(|s| !s.is_empty())
136        })
137        .map(str::to_string);
138
139    let whatsapp = person
140        .get("whatsapp_number")
141        .and_then(|v| v.as_str())
142        .filter(|s| !s.is_empty())
143        .map(str::to_string)
144        .or_else(|| phone.clone());
145
146    let display = person
147        .get("display_name")
148        .and_then(|v| v.as_str())
149        .filter(|s| !s.is_empty())
150        .or_else(|| {
151            person
152                .get("name")
153                .and_then(|v| v.as_str())
154                .filter(|s| !s.is_empty())
155        })
156        .map(str::to_string);
157
158    let email = person.get("email").and_then(|v| {
159        if v.is_string() {
160            Some(v.clone())
161        } else if v.is_null() {
162            Some(Value::Null)
163        } else {
164            Some(Value::Null)
165        }
166    });
167
168    let mut out = Value::Object(person.clone());
169    let obj = out.as_object_mut().unwrap();
170    obj.insert("id".into(), json!(id));
171    obj.insert(
172        "phone_number".into(),
173        phone.map(Value::String).unwrap_or(Value::Null),
174    );
175    obj.insert(
176        "whatsapp_number".into(),
177        whatsapp.map(Value::String).unwrap_or(Value::Null),
178    );
179    obj.insert(
180        "display_name".into(),
181        display.map(Value::String).unwrap_or(Value::Null),
182    );
183    if let Some(email) = email {
184        obj.insert("email".into(), email);
185    } else {
186        obj.insert("email".into(), Value::Null);
187    }
188    Some(out)
189}
190
191pub fn pick_identity(input: Option<&Value>, identity: &Value) -> Map<String, Value> {
192    let mut merged = Map::new();
193    if let Some(obj) = identity.as_object() {
194        for (k, v) in obj {
195            merged.insert(k.clone(), v.clone());
196        }
197    }
198    if let Some(Value::Object(obj)) = input {
199        for (k, v) in obj {
200            merged.insert(k.clone(), v.clone());
201        }
202    }
203
204    let phone = merged
205        .get("phone_number")
206        .and_then(|v| v.as_str())
207        .filter(|s| !s.is_empty())
208        .or_else(|| {
209            merged
210                .get("phone")
211                .and_then(|v| v.as_str())
212                .filter(|s| !s.is_empty())
213        })
214        .map(str::to_string);
215
216    let whatsapp = merged
217        .get("whatsapp_number")
218        .and_then(|v| v.as_str())
219        .filter(|s| !s.is_empty())
220        .map(str::to_string);
221
222    let email = merged
223        .get("email")
224        .and_then(|v| v.as_str())
225        .filter(|s| !s.is_empty())
226        .map(str::to_string);
227
228    let display = merged
229        .get("display_name")
230        .and_then(|v| v.as_str())
231        .filter(|s| !s.is_empty())
232        .or_else(|| {
233            merged
234                .get("name")
235                .and_then(|v| v.as_str())
236                .filter(|s| !s.is_empty())
237        })
238        .map(str::to_string);
239
240    let channel = merged
241        .get("channel")
242        .and_then(|v| v.as_str())
243        .filter(|s| !s.is_empty())
244        .map(str::to_string)
245        .unwrap_or_else(|| {
246            if whatsapp.is_some() {
247                "whatsapp".into()
248            } else if phone.is_some() {
249                "sms".into()
250            } else if email.is_some() {
251                "email".into()
252            } else {
253                "api".into()
254            }
255        });
256
257    let identifier = merged
258        .get("identifier")
259        .and_then(|v| v.as_str())
260        .filter(|s| !s.is_empty())
261        .map(str::to_string)
262        .or_else(|| whatsapp.clone())
263        .or_else(|| phone.clone())
264        .or_else(|| email.clone())
265        .or_else(|| {
266            merged
267                .get("id")
268                .and_then(|v| v.as_str())
269                .map(str::to_string)
270        });
271
272    let mut out = Map::new();
273    if let Some(id) = merged.get("id").and_then(|v| v.as_str()) {
274        out.insert("id".into(), json!(id));
275    }
276    if let Some(phone) = phone {
277        out.insert("phone_number".into(), json!(phone));
278    }
279    if let Some(whatsapp) = whatsapp {
280        out.insert("whatsapp_number".into(), json!(whatsapp));
281    }
282    if let Some(email) = email {
283        out.insert("email".into(), json!(email));
284    }
285    if let Some(display) = display {
286        out.insert("display_name".into(), json!(display));
287    }
288    out.insert("channel".into(), json!(channel));
289    if let Some(identifier) = identifier {
290        out.insert("identifier".into(), json!(identifier));
291    }
292    out
293}
294
295fn resolve_hub_endpoint(
296    platform: Option<&PlatformCapabilities>,
297) -> Option<(String, String, PlatformCustomerContext)> {
298    let from_env = std::env::var("QEFRO_CUSTOMER_HUB_URL")
299        .ok()
300        .map(|s| s.trim_end_matches('/').to_string())
301        .filter(|s| !s.is_empty());
302
303    let customer = platform.and_then(|p| p.customer.as_ref());
304    let base_url = customer
305        .and_then(|c| c.base_url.as_ref())
306        .map(|s| s.trim_end_matches('/').to_string())
307        .filter(|s| !s.is_empty())
308        .or(from_env)?;
309
310    let context = customer.and_then(|c| c.context.clone())?;
311    if context.tenant_id.is_empty() || context.workspace_id.is_empty() {
312        return None;
313    }
314
315    let token = customer
316        .and_then(|c| c.token.clone())
317        .filter(|s| !s.is_empty())
318        .or_else(|| std::env::var("QEFRO_SERVICE_TOKEN").ok())
319        .or_else(|| std::env::var("QEFRO_INTERNAL_TOKEN").ok())
320        .or_else(|| std::env::var("QEFRO_INTERNAL_BEARER").ok())
321        .unwrap_or_default();
322
323    Some((base_url, token, context))
324}
325
326/// POST `/v1/internal/customer-hub/{op}`. Soft-skip or hard-fail per flags.
327pub async fn hub_call(
328    platform: Option<&PlatformCapabilities>,
329    op: &str,
330    body: Value,
331) -> Result<Option<Value>> {
332    if !is_customer_hub_enabled() {
333        if is_customer_hub_optional() {
334            return Ok(None);
335        }
336        return Err(anyhow!("customer_hub_disabled"));
337    }
338
339    let Some((base_url, token, context)) = resolve_hub_endpoint(platform) else {
340        if is_customer_hub_optional() {
341            return Ok(None);
342        }
343        return Err(anyhow!("customer_hub_unavailable"));
344    };
345
346    let mut payload = body;
347    if let Some(obj) = payload.as_object_mut() {
348        obj.insert("context".into(), serde_json::to_value(&context)?);
349    }
350
351    let client = reqwest::Client::new();
352    let mut req = client
353        .post(format!("{base_url}/v1/internal/customer-hub/{op}"))
354        .header("content-type", "application/json")
355        .json(&payload);
356    if !token.is_empty() {
357        req = req.bearer_auth(token);
358    }
359
360    let res = match req.send().await {
361        Ok(r) => r,
362        Err(err) => {
363            if is_customer_hub_optional() {
364                return Ok(None);
365            }
366            return Err(anyhow!("customer_hub.{op} failed: {err}"));
367        }
368    };
369
370    let status = res.status().as_u16();
371    let text = res.text().await.unwrap_or_default();
372    if !(200..300).contains(&status) {
373        if is_customer_hub_optional() && (status == 404 || status >= 500) {
374            return Ok(None);
375        }
376        return Err(anyhow!("customer_hub.{op} failed ({status}): {text}"));
377    }
378    if text.is_empty() {
379        return Ok(Some(json!({})));
380    }
381    let parsed: Value = serde_json::from_str(&text)?;
382    Ok(Some(parsed))
383}
384
385fn current_customer_id(state: &CustomerState) -> Option<String> {
386    state
387        .current
388        .as_ref()
389        .and_then(|v| v.get("id"))
390        .and_then(|v| v.as_str())
391        .filter(|s| !s.is_empty())
392        .map(str::to_string)
393}
394
395/// `ctx.timeline` — append Customer Hub timeline activities.
396#[derive(Clone)]
397pub struct TimelineContext {
398    pub platform: Option<PlatformCapabilities>,
399    pub state: Arc<Mutex<CustomerState>>,
400}
401
402impl TimelineContext {
403    pub async fn append(&self, input: Value) -> Result<()> {
404        let event_type = input
405            .get("event_type")
406            .and_then(|v| v.as_str())
407            .unwrap_or("")
408            .trim()
409            .to_string();
410        if event_type.is_empty() {
411            return Err(anyhow!("timeline_event_empty"));
412        }
413        let customer_id = input
414            .get("customer_id")
415            .and_then(|v| v.as_str())
416            .map(str::to_string)
417            .or_else(|| {
418                // fall through to state below
419                None
420            });
421        let customer_id = match customer_id {
422            Some(id) => id,
423            None => {
424                let state = self.state.lock().await;
425                match current_customer_id(&state) {
426                    Some(id) => id,
427                    None if is_customer_hub_optional() => return Ok(()),
428                    None => return Err(anyhow!("customer_not_found")),
429                }
430            }
431        };
432        let payload = input.get("payload").cloned().unwrap_or_else(|| json!({}));
433        let source = input
434            .get("source")
435            .and_then(|v| v.as_str())
436            .unwrap_or("sdk");
437        hub_call(
438            self.platform.as_ref(),
439            "timeline_append",
440            json!({
441                "customer_id": customer_id,
442                "event_type": event_type,
443                "payload": payload,
444                "source": source,
445            }),
446        )
447        .await?;
448        Ok(())
449    }
450}
451
452/// `ctx.membership` — attach/detach solution membership.
453#[derive(Clone)]
454pub struct MembershipContext {
455    pub platform: Option<PlatformCapabilities>,
456    pub state: Arc<Mutex<CustomerState>>,
457    pub solution_id: Option<String>,
458}
459
460impl MembershipContext {
461    async fn customer_id_or_optional(&self, input: Option<&Value>) -> Result<Option<String>> {
462        if let Some(id) = input
463            .and_then(|v| v.get("customer_id"))
464            .and_then(|v| v.as_str())
465            .filter(|s| !s.is_empty())
466        {
467            return Ok(Some(id.to_string()));
468        }
469        let state = self.state.lock().await;
470        if let Some(id) = current_customer_id(&state) {
471            return Ok(Some(id));
472        }
473        if is_customer_hub_optional() {
474            return Ok(None);
475        }
476        Err(anyhow!("customer_not_found"))
477    }
478
479    pub async fn attach(&self, input: Option<Value>) -> Result<()> {
480        let customer_id = match self.customer_id_or_optional(input.as_ref()).await? {
481            Some(id) => id,
482            None => return Ok(()),
483        };
484        let solution_id = input
485            .as_ref()
486            .and_then(|v| v.get("solution_id"))
487            .and_then(|v| v.as_str())
488            .map(str::to_string)
489            .or_else(|| self.solution_id.clone());
490        let role = input
491            .as_ref()
492            .and_then(|v| v.get("role"))
493            .cloned()
494            .unwrap_or(Value::Null);
495        let metadata = input
496            .as_ref()
497            .and_then(|v| v.get("metadata"))
498            .cloned()
499            .unwrap_or_else(|| json!({}));
500        hub_call(
501            self.platform.as_ref(),
502            "membership_attach",
503            json!({
504                "customer_id": customer_id,
505                "solution_id": solution_id,
506                "role": role,
507                "metadata": metadata,
508            }),
509        )
510        .await?;
511        Ok(())
512    }
513
514    pub async fn detach(&self, input: Option<Value>) -> Result<()> {
515        let customer_id = match self.customer_id_or_optional(input.as_ref()).await? {
516            Some(id) => id,
517            None => return Ok(()),
518        };
519        let solution_id = input
520            .as_ref()
521            .and_then(|v| v.get("solution_id"))
522            .and_then(|v| v.as_str())
523            .map(str::to_string)
524            .or_else(|| self.solution_id.clone());
525        let role = input
526            .as_ref()
527            .and_then(|v| v.get("role"))
528            .cloned()
529            .unwrap_or(Value::Null);
530        let metadata = input
531            .as_ref()
532            .and_then(|v| v.get("metadata"))
533            .cloned()
534            .unwrap_or_else(|| json!({}));
535        hub_call(
536            self.platform.as_ref(),
537            "membership_detach",
538            json!({
539                "customer_id": customer_id,
540                "solution_id": solution_id,
541                "role": role,
542                "metadata": metadata,
543            }),
544        )
545        .await?;
546        Ok(())
547    }
548}
549
550/// `ctx.consent` — grant/revoke consent purposes.
551#[derive(Clone)]
552pub struct ConsentContext {
553    pub platform: Option<PlatformCapabilities>,
554    pub state: Arc<Mutex<CustomerState>>,
555}
556
557impl ConsentContext {
558    async fn customer_id_or_optional(&self, input: &Value) -> Result<Option<String>> {
559        if let Some(id) = input
560            .get("customer_id")
561            .and_then(|v| v.as_str())
562            .filter(|s| !s.is_empty())
563        {
564            return Ok(Some(id.to_string()));
565        }
566        let state = self.state.lock().await;
567        if let Some(id) = current_customer_id(&state) {
568            return Ok(Some(id));
569        }
570        if is_customer_hub_optional() {
571            return Ok(None);
572        }
573        Err(anyhow!("customer_not_found"))
574    }
575
576    pub async fn grant(&self, input: Value) -> Result<()> {
577        let purpose = input
578            .get("purpose")
579            .and_then(|v| v.as_str())
580            .unwrap_or("")
581            .trim()
582            .to_string();
583        if purpose.is_empty() {
584            return Err(anyhow!("consent_purpose_empty"));
585        }
586        let customer_id = match self.customer_id_or_optional(&input).await? {
587            Some(id) => id,
588            None => return Ok(()),
589        };
590        let metadata = input.get("metadata").cloned().unwrap_or_else(|| json!({}));
591        hub_call(
592            self.platform.as_ref(),
593            "consent_grant",
594            json!({
595                "customer_id": customer_id,
596                "purpose": purpose,
597                "metadata": metadata,
598            }),
599        )
600        .await?;
601        Ok(())
602    }
603
604    pub async fn revoke(&self, input: Value) -> Result<()> {
605        let purpose = input
606            .get("purpose")
607            .and_then(|v| v.as_str())
608            .unwrap_or("")
609            .trim()
610            .to_string();
611        if purpose.is_empty() {
612            return Err(anyhow!("consent_purpose_empty"));
613        }
614        let customer_id = match self.customer_id_or_optional(&input).await? {
615            Some(id) => id,
616            None => return Ok(()),
617        };
618        let metadata = input.get("metadata").cloned().unwrap_or_else(|| json!({}));
619        hub_call(
620            self.platform.as_ref(),
621            "consent_revoke",
622            json!({
623                "customer_id": customer_id,
624                "purpose": purpose,
625                "metadata": metadata,
626            }),
627        )
628        .await?;
629        Ok(())
630    }
631}
632
633/// Seed hub customer projection from a Person snapshot on `tool.invoke`.
634pub fn seed_from_person(person: &Value) -> Option<Value> {
635    let id = person.get("id")?.as_str()?.to_string();
636    if id.is_empty() {
637        return None;
638    }
639    Some(json!({
640        "id": id,
641        "phone_number": person.get("phone").cloned().unwrap_or(Value::Null),
642        "whatsapp_number": person.get("phone").cloned().unwrap_or(Value::Null),
643        "display_name": person.get("name").cloned().unwrap_or(Value::Null),
644        "email": person.get("email").cloned().unwrap_or(Value::Null),
645        "status": person.get("status").cloned().unwrap_or(Value::Null),
646        "workspace_id": person.get("workspace_id").cloned().unwrap_or(Value::Null),
647    }))
648}
649
650#[cfg(test)]
651pub(crate) static HUB_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
652
653#[cfg(test)]
654mod tests {
655    use super::*;
656
657    #[test]
658    fn flags_default_off_optional() {
659        let _guard = HUB_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
660        std::env::remove_var("QEFRO_CUSTOMER_HUB_ENABLED");
661        std::env::remove_var("QEFRO_CUSTOMER_HUB_OPTIONAL");
662        assert!(!is_customer_hub_enabled());
663        assert!(is_customer_hub_optional());
664    }
665
666    #[test]
667    fn hub_customer_maps_person_fields() {
668        let hub = hub_customer_from_person(Some(&json!({
669            "id": "p1",
670            "name": "Ada",
671            "phone": "+1555",
672            "email": "a@b.c",
673        })))
674        .unwrap();
675        assert_eq!(hub["id"], "p1");
676        assert_eq!(hub["display_name"], "Ada");
677        assert_eq!(hub["phone_number"], "+1555");
678        assert_eq!(hub["whatsapp_number"], "+1555");
679        assert_eq!(hub["email"], "a@b.c");
680        assert!(hub_customer_from_person(Some(&json!({"name": "x"}))).is_none());
681        assert!(hub_customer_from_person(None).is_none());
682    }
683
684    #[tokio::test]
685    async fn resolve_soft_skips_when_disabled() {
686        let _guard = HUB_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
687        std::env::set_var("QEFRO_CUSTOMER_HUB_ENABLED", "false");
688        std::env::set_var("QEFRO_CUSTOMER_HUB_OPTIONAL", "true");
689        let out = hub_call(None, "resolve", json!({"phone_number": "+1"}))
690            .await
691            .unwrap();
692        assert!(out.is_none());
693        std::env::remove_var("QEFRO_CUSTOMER_HUB_ENABLED");
694        std::env::remove_var("QEFRO_CUSTOMER_HUB_OPTIONAL");
695    }
696
697    #[tokio::test]
698    async fn resolve_hard_fails_when_required() {
699        let _guard = HUB_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
700        std::env::set_var("QEFRO_CUSTOMER_HUB_ENABLED", "true");
701        std::env::set_var("QEFRO_CUSTOMER_HUB_OPTIONAL", "false");
702        std::env::remove_var("QEFRO_CUSTOMER_HUB_URL");
703        let err = hub_call(None, "resolve", json!({"phone_number": "+1"}))
704            .await
705            .unwrap_err();
706        assert!(err.to_string().contains("customer_hub_unavailable"));
707        std::env::remove_var("QEFRO_CUSTOMER_HUB_ENABLED");
708        std::env::remove_var("QEFRO_CUSTOMER_HUB_OPTIONAL");
709    }
710}