Skip to main content

qefro_backend_sdk/
organization.rs

1//! Organization capability registration (ADR-005 Phase 1).
2//!
3//! Apps register metadata only — events, actions, tasks. The platform owns the
4//! capability graph, workflows, and inbox. Templates are organization-owned (Phase 4).
5
6use serde::{Deserialize, Serialize};
7use serde_json::Value;
8use std::collections::HashSet;
9
10/// Error from [`validate_organization_definition`] / [`Qefro::organization`](crate::Qefro::organization).
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub enum OrganizationError {
13    Message(String),
14}
15
16impl std::fmt::Display for OrganizationError {
17    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
18        match self {
19            OrganizationError::Message(m) => write!(f, "{m}"),
20        }
21    }
22}
23
24impl std::error::Error for OrganizationError {}
25
26fn err(msg: impl Into<String>) -> OrganizationError {
27    OrganizationError::Message(msg.into())
28}
29
30#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
31pub struct OrganizationEvent {
32    pub id: String,
33    #[serde(default, skip_serializing_if = "Option::is_none")]
34    pub label: Option<String>,
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    pub description: Option<String>,
37    #[serde(default, skip_serializing_if = "Option::is_none", rename = "payloadSchema")]
38    pub payload_schema: Option<Value>,
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
42pub struct OrganizationAction {
43    pub id: String,
44    #[serde(default, skip_serializing_if = "Option::is_none")]
45    pub label: Option<String>,
46    #[serde(default, skip_serializing_if = "Option::is_none")]
47    pub description: Option<String>,
48    #[serde(default, skip_serializing_if = "Option::is_none", rename = "inputSchema")]
49    pub input_schema: Option<Value>,
50    #[serde(default, skip_serializing_if = "Option::is_none", rename = "outputSchema")]
51    pub output_schema: Option<Value>,
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
55pub struct OrganizationTaskType {
56    pub id: String,
57    pub label: String,
58    #[serde(default, skip_serializing_if = "Option::is_none")]
59    pub description: Option<String>,
60    #[serde(
61        default,
62        skip_serializing_if = "Option::is_none",
63        rename = "suggested_workspace_type"
64    )]
65    pub suggested_workspace_type: Option<String>,
66    #[serde(default, skip_serializing_if = "Option::is_none", rename = "suggested_team")]
67    pub suggested_team: Option<String>,
68    #[serde(default, skip_serializing_if = "Option::is_none")]
69    pub priority: Option<String>,
70}
71
72#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
73pub struct OrganizationDefinition {
74    #[serde(default)]
75    pub version: Option<i64>,
76    #[serde(default)]
77    pub events: Option<Vec<OrganizationEvent>>,
78    #[serde(default)]
79    pub actions: Option<Vec<OrganizationAction>>,
80    #[serde(default)]
81    pub tasks: Option<Vec<OrganizationTaskType>>,
82    /// Rejected in Phase 1 — templates are organization-owned (Phase 4).
83    #[serde(default)]
84    pub templates: Option<Value>,
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
88pub struct OrganizationCapabilities {
89    pub version: i64,
90    pub events: Vec<OrganizationEvent>,
91    pub actions: Vec<OrganizationAction>,
92    pub tasks: Vec<OrganizationTaskType>,
93}
94
95#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
96pub struct OrganizationMetadata {
97    pub events: Vec<OrganizationEvent>,
98    pub actions: Vec<OrganizationAction>,
99    pub tasks: Vec<OrganizationTaskType>,
100}
101
102#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
103pub struct OrganizationCapability {
104    pub version: i64,
105    pub metadata: OrganizationMetadata,
106}
107
108fn require_non_empty(value: &str, path: &str) -> Result<String, OrganizationError> {
109    let t = value.trim();
110    if t.is_empty() {
111        return Err(err(format!("organization: {path} must be a non-empty string")));
112    }
113    Ok(t.to_string())
114}
115
116fn assert_opaque(id: &str, path: &str) -> Result<(), OrganizationError> {
117    if id.contains('.') {
118        return Err(err(format!(
119            "organization: {path} must be an opaque capability id (no '.' / app prefix); got \"{id}\""
120        )));
121    }
122    Ok(())
123}
124
125fn assert_unique(ids: &[String], kind: &str) -> Result<(), OrganizationError> {
126    let mut seen = HashSet::new();
127    for id in ids {
128        if !seen.insert(id.clone()) {
129            return Err(err(format!("organization: duplicate {kind} id \"{id}\"")));
130        }
131    }
132    Ok(())
133}
134
135/// Validate and normalize an organization definition.
136pub fn validate_organization_definition(
137    def: OrganizationDefinition,
138) -> Result<OrganizationCapabilities, OrganizationError> {
139    if def.templates.is_some() {
140        return Err(err(
141            "organization: templates are organization-owned assets (Phase 4); do not publish from apps",
142        ));
143    }
144
145    let version = match def.version {
146        None => 1,
147        Some(v) if v >= 1 => v,
148        Some(_) => return Err(err("organization: version must be a positive integer")),
149    };
150
151    let mut events = def.events.unwrap_or_default();
152    let mut actions = def.actions.unwrap_or_default();
153    let mut tasks = def.tasks.unwrap_or_default();
154
155    for (i, e) in events.iter_mut().enumerate() {
156        e.id = require_non_empty(&e.id, &format!("events[{i}].id"))?;
157        assert_opaque(&e.id, &format!("events[{i}].id"))?;
158        if let Some(ref label) = e.label {
159            let trimmed = label.trim();
160            e.label = if trimmed.is_empty() {
161                None
162            } else {
163                Some(trimmed.to_string())
164            };
165        }
166    }
167    for (i, a) in actions.iter_mut().enumerate() {
168        a.id = require_non_empty(&a.id, &format!("actions[{i}].id"))?;
169        assert_opaque(&a.id, &format!("actions[{i}].id"))?;
170        if let Some(ref label) = a.label {
171            let trimmed = label.trim();
172            a.label = if trimmed.is_empty() {
173                None
174            } else {
175                Some(trimmed.to_string())
176            };
177        }
178    }
179    for (i, t) in tasks.iter_mut().enumerate() {
180        t.id = require_non_empty(&t.id, &format!("tasks[{i}].id"))?;
181        assert_opaque(&t.id, &format!("tasks[{i}].id"))?;
182        t.label = require_non_empty(&t.label, &format!("tasks[{i}].label"))?;
183    }
184
185    assert_unique(
186        &events.iter().map(|e| e.id.clone()).collect::<Vec<_>>(),
187        "event",
188    )?;
189    assert_unique(
190        &actions.iter().map(|a| a.id.clone()).collect::<Vec<_>>(),
191        "action",
192    )?;
193    assert_unique(
194        &tasks.iter().map(|t| t.id.clone()).collect::<Vec<_>>(),
195        "task",
196    )?;
197
198    let mut all = Vec::new();
199    all.extend(events.iter().map(|e| e.id.clone()));
200    all.extend(actions.iter().map(|a| a.id.clone()));
201    all.extend(tasks.iter().map(|t| t.id.clone()));
202    assert_unique(&all, "capability")?;
203
204    Ok(OrganizationCapabilities {
205        version,
206        events,
207        actions,
208        tasks,
209    })
210}
211
212/// Build the capabilities.list.organization wire object.
213pub fn to_organization_capability(caps: &OrganizationCapabilities) -> OrganizationCapability {
214    OrganizationCapability {
215        version: caps.version,
216        metadata: OrganizationMetadata {
217            events: caps.events.clone(),
218            actions: caps.actions.clone(),
219            tasks: caps.tasks.clone(),
220        },
221    }
222}
223
224pub fn is_organization_enabled() -> bool {
225    crate::customer_hub::env_flag_true("QEFRO_ORGANIZATION_ENABLED", false)
226}
227
228/// Thin get-own-capabilities client via `platform.organization`. Registry read only.
229#[derive(Clone, Default)]
230pub struct OrganizationContext {
231    platform: Option<crate::customer_hub::PlatformCapabilities>,
232}
233
234impl OrganizationContext {
235    pub fn new(platform: Option<crate::customer_hub::PlatformCapabilities>) -> Self {
236        Self { platform }
237    }
238
239    pub async fn get_capabilities(&self) -> anyhow::Result<Option<Value>> {
240        if !is_organization_enabled() {
241            return Ok(None);
242        }
243        let binding = self.platform.as_ref().and_then(|p| p.organization.as_ref());
244        let from_env = std::env::var("QEFRO_ORGANIZATION_URL")
245            .ok()
246            .map(|s| s.trim_end_matches('/').to_string());
247        let base_url = binding
248            .and_then(|b| b.base_url.clone())
249            .or(from_env)
250            .unwrap_or_default()
251            .trim_end_matches('/')
252            .to_string();
253        let context = binding.and_then(|b| b.context.as_ref());
254        if base_url.is_empty() || context.is_none() {
255            return Ok(None);
256        }
257        let context = context.unwrap();
258        if context.tenant_id.is_empty() || context.workspace_id.is_empty() {
259            return Ok(None);
260        }
261        let solution_id = match &context.solution_id {
262            Some(id) if !id.is_empty() => id.clone(),
263            _ => return Ok(None),
264        };
265        let token = binding
266            .and_then(|b| b.token.clone())
267            .or_else(|| std::env::var("QEFRO_SERVICE_TOKEN").ok())
268            .or_else(|| std::env::var("QEFRO_INTERNAL_TOKEN").ok())
269            .or_else(|| std::env::var("QEFRO_INTERNAL_BEARER").ok())
270            .unwrap_or_default();
271        let encoded: String = solution_id
272            .bytes()
273            .map(|b| match b {
274                b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
275                    (b as char).to_string()
276                }
277                _ => format!("%{b:02X}"),
278            })
279            .collect();
280        let url = format!("{base_url}/v1/internal/organization/capabilities/{encoded}");
281        let mut req = reqwest::Client::new()
282            .get(&url)
283            .header("content-type", "application/json")
284            .header("x-qefro-tenant-id", &context.tenant_id)
285            .header("x-qefro-workspace-id", &context.workspace_id);
286        if let Some(ref installation_id) = context.installation_id {
287            if !installation_id.is_empty() {
288                req = req.header("x-qefro-installation-id", installation_id);
289            }
290        }
291        if !token.is_empty() {
292            req = req.header("authorization", format!("Bearer {token}"));
293        }
294        let res = req.send().await?;
295        let status = res.status();
296        let text = res.text().await.unwrap_or_default();
297        if status.as_u16() == 404 {
298            return Ok(None);
299        }
300        if !status.is_success() {
301            return Err(anyhow::anyhow!(
302                "organization.getCapabilities failed ({status}): {text}"
303            ));
304        }
305        if text.is_empty() {
306            return Ok(None);
307        }
308        Ok(serde_json::from_str(&text).ok())
309    }
310}
311
312pub fn build_organization_context(
313    platform: Option<crate::customer_hub::PlatformCapabilities>,
314) -> OrganizationContext {
315    OrganizationContext::new(platform)
316}
317
318#[cfg(test)]
319mod tests {
320    use super::*;
321
322    #[test]
323    fn accepts_valid_definition() {
324        let caps = validate_organization_definition(OrganizationDefinition {
325            version: Some(1),
326            events: Some(vec![OrganizationEvent {
327                id: "purchase_requested".into(),
328                label: Some("Purchase requested".into()),
329                description: None,
330                payload_schema: None,
331            }]),
332            actions: Some(vec![OrganizationAction {
333                id: "approve_purchase".into(),
334                label: Some("Approve".into()),
335                description: None,
336                input_schema: None,
337                output_schema: None,
338            }]),
339            tasks: Some(vec![OrganizationTaskType {
340                id: "purchase_approval".into(),
341                label: "Purchase approval".into(),
342                description: None,
343                suggested_workspace_type: Some("finance".into()),
344                suggested_team: None,
345                priority: Some("high".into()),
346            }]),
347            templates: None,
348        })
349        .unwrap();
350        assert_eq!(caps.events[0].id, "purchase_requested");
351        let wire = to_organization_capability(&caps);
352        assert_eq!(wire.metadata.actions[0].id, "approve_purchase");
353    }
354
355    #[test]
356    fn rejects_prefixed_ids() {
357        let err = validate_organization_definition(OrganizationDefinition {
358            events: Some(vec![OrganizationEvent {
359                id: "restaurant.purchase_requested".into(),
360                label: None,
361                description: None,
362                payload_schema: None,
363            }]),
364            ..Default::default()
365        })
366        .unwrap_err();
367        assert!(err.to_string().contains("opaque capability id"));
368    }
369
370    #[test]
371    fn rejects_templates() {
372        let err = validate_organization_definition(OrganizationDefinition {
373            templates: Some(serde_json::json!([{"id": "x"}])),
374            ..Default::default()
375        })
376        .unwrap_err();
377        assert!(err.to_string().contains("templates are organization-owned"));
378    }
379
380    #[tokio::test]
381    async fn organization_context_disabled_without_flag() {
382        assert!(!is_organization_enabled());
383        let ctx = build_organization_context(None);
384        assert!(ctx.get_capabilities().await.unwrap().is_none());
385    }
386}