1use anyhow::{anyhow, Result};
8use serde::{Deserialize, Serialize};
9use serde_json::{json, Map, Value};
10use std::sync::Arc;
11use tokio::sync::Mutex;
12
13#[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#[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#[derive(Debug, Clone, Serialize, Deserialize, Default)]
71pub struct PlatformCapabilities {
72 #[serde(default, skip_serializing_if = "Option::is_none")]
73 pub storage: Option<PlatformStorageBinding>,
74 #[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
96pub fn is_customer_hub_enabled() -> bool {
98 env_flag_true("QEFRO_CUSTOMER_HUB_ENABLED", false)
99}
100
101pub 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
119pub 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
326pub 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#[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 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#[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#[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
633pub 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}