1use serde::{Deserialize, Serialize};
7use serde_json::Value;
8use std::collections::HashSet;
9
10#[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 #[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
135pub 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
212pub 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#[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}