1use crate::config::resolved::ResolvedEntity;
12use crate::config::types::{EntityEventTrigger, EventCondition};
13use serde_json::Value;
14use std::sync::Arc;
15
16pub struct DecisionHubClient {
17 base_url: String,
18 client: reqwest::Client,
19}
20
21impl DecisionHubClient {
22 pub fn from_env() -> Option<Arc<Self>> {
23 let base_url = std::env::var("DECISION_HUB_URL").ok()?;
24 let timeout_secs: u64 = std::env::var("DECISION_HUB_TIMEOUT_SECS")
25 .ok()
26 .and_then(|s| s.parse().ok())
27 .unwrap_or(5);
28 let client = reqwest::Client::builder()
29 .timeout(std::time::Duration::from_secs(timeout_secs))
30 .build()
31 .ok()?;
32 tracing::info!(url = %base_url, "decision-hub event publishing enabled");
33 Some(Arc::new(Self { base_url, client }))
34 }
35
36 async fn publish(&self, tenant_id: &str, event_type: &str, context: Value) {
37 let payload = serde_json::json!({
38 "tenant_id": tenant_id,
39 "event_type": event_type,
40 "context": context,
41 });
42 let url = format!("{}/evaluate", self.base_url);
43 log_curl(&url, &payload);
44 match self.client.post(&url).json(&payload).send().await {
45 Ok(resp) if !resp.status().is_success() => {
46 let status = resp.status().as_u16();
47 let body = resp.text().await.unwrap_or_default();
48 tracing::warn!(
49 event_type = %event_type,
50 status = %status,
51 body = %body,
52 "decision-hub rejected event"
53 );
54 }
55 Err(e) => {
56 tracing::warn!(event_type = %event_type, error = %e, "decision-hub publish failed");
57 }
58 Ok(resp) => {
59 let body = resp.text().await.unwrap_or_default();
62 tracing::info!(
63 event_type = %event_type,
64 response = %body,
65 "decision-hub event accepted"
66 );
67 }
68 }
69 }
70}
71
72fn log_curl(url: &str, payload: &Value) {
79 let force = std::env::var("DECISION_HUB_LOG_CURL")
80 .map(|v| matches!(v.as_str(), "1" | "true" | "TRUE"))
81 .unwrap_or(false);
82 if !force && !tracing::enabled!(tracing::Level::DEBUG) {
83 return;
84 }
85 let body = serde_json::to_string(payload).unwrap_or_default();
86 let curl = format!(
88 "curl -sS -X POST '{}' -H 'Content-Type: application/json' --data-raw '{}'",
89 url,
90 body.replace('\'', r#"'\''"#),
91 );
92 if force {
93 tracing::info!(curl = %curl, "decision-hub request");
94 } else {
95 tracing::debug!(curl = %curl, "decision-hub request");
96 }
97}
98
99fn evaluate_condition(
106 condition: &EventCondition,
107 row: &Value,
108 pre_update_row: Option<&Value>,
109) -> bool {
110 let new_val = row.get(&condition.field);
111 if let Some(target) = &condition.changed_to {
112 let now_matches = new_val == Some(target);
113 return match pre_update_row {
114 Some(old_row) => now_matches && old_row.get(&condition.field) != Some(target),
116 None => now_matches,
118 };
119 }
120 if let Some(target) = &condition.equals {
121 return new_val == Some(target);
122 }
123 if let Some(not_null) = condition.not_null {
124 let is_not_null = matches!(new_val, Some(v) if !v.is_null());
125 return is_not_null == not_null;
126 }
127 true
128}
129
130fn default_event_name(on: &str) -> &str {
131 match on {
132 "create" => "created",
133 "update" => "updated",
134 "delete" => "deleted",
135 "archive" => "archived",
136 other => other,
137 }
138}
139
140fn trigger_matches(
142 trigger: &EntityEventTrigger,
143 lifecycle: &str,
144 raw_row: &Value,
145 archive_field: Option<&str>,
146 pre_update_row: Option<&Value>,
147) -> bool {
148 match trigger.on.as_str() {
149 on if on == lifecycle => {
150 if let Some(cond) = &trigger.condition {
151 evaluate_condition(cond, raw_row, pre_update_row)
152 } else {
153 true
154 }
155 }
156 "archive" if lifecycle == "update" => archive_field
158 .and_then(|f| raw_row.get(f))
159 .map(|v| !v.is_null())
160 .unwrap_or(false),
161 _ => false,
162 }
163}
164
165pub fn spawn_events(
178 client: Arc<DecisionHubClient>,
179 entity: &ResolvedEntity,
180 lifecycle: &'static str,
181 raw_row: Value,
182 api_row: Value,
183 tenant_id: String,
184 pre_update_row: Option<Value>,
185) {
186 spawn_events_with(
187 client,
188 entity,
189 lifecycle,
190 raw_row,
191 api_row,
192 tenant_id,
193 pre_update_row,
194 None,
195 );
196}
197
198#[derive(Clone)]
203pub struct EventIncludeCtx {
204 pub pool: crate::db::pool::Pool,
205 pub rls_tenant: Option<String>,
208 pub schema_override: Option<String>,
209 pub dialect: Arc<dyn crate::db::Dialect>,
210 pub entity: ResolvedEntity,
211 pub resolved: Vec<(String, crate::config::IncludeSpec, ResolvedEntity)>,
214 pub pk_column: String,
215 pub pk_value: String,
217}
218
219impl EventIncludeCtx {
220 pub fn with_pk_value(&self, pk_value: String) -> Self {
223 Self {
224 pk_value,
225 ..self.clone()
226 }
227 }
228}
229
230#[allow(clippy::too_many_arguments)]
232pub fn spawn_events_with(
233 client: Arc<DecisionHubClient>,
234 entity: &ResolvedEntity,
235 lifecycle: &'static str,
236 raw_row: Value,
237 api_row: Value,
238 tenant_id: String,
239 pre_update_row: Option<Value>,
240 include_ctx: Option<EventIncludeCtx>,
241) {
242 if entity.events.is_empty() {
243 return;
244 }
245
246 let triggers: Vec<EntityEventTrigger> = entity
247 .events
248 .iter()
249 .filter(|t| {
250 trigger_matches(
251 t,
252 lifecycle,
253 &raw_row,
254 entity.archive_field.as_deref(),
255 pre_update_row.as_ref(),
256 )
257 })
258 .cloned()
259 .collect();
260
261 if triggers.is_empty() {
262 return;
263 }
264
265 let package_id = entity.package_id.clone();
266 let table_name = entity.table_name.clone();
267 let sensitive_columns = entity.sensitive_columns.clone();
268
269 let previous = match (lifecycle, pre_update_row) {
273 ("update", Some(mut old)) => {
274 crate::handlers::entity::strip_sensitive_columns(&mut old, &sensitive_columns);
275 crate::case::value_keys_to_camel_case(&mut old);
276 Some(old)
277 }
278 _ => None,
279 };
280
281 tokio::spawn(async move {
282 let mut expanded: std::collections::HashMap<String, Value> =
285 std::collections::HashMap::new();
286
287 for trigger in &triggers {
288 let suffix = trigger
289 .event_name
290 .as_deref()
291 .unwrap_or_else(|| default_event_name(trigger.on.as_str()));
292 let event_type = format!("{}.{}:{}", package_id, table_name, suffix);
293 tracing::info!(
294 tenant_id = %tenant_id,
295 event_type = %event_type,
296 lifecycle = %lifecycle,
297 "publishing decision-hub event"
298 );
299
300 let entity_value = match (&include_ctx, trigger.include.is_empty(), lifecycle) {
301 (_, true, _) | (None, _, _) | (_, _, "delete") => api_row.clone(),
303 (Some(ctx), false, _) => {
304 let mut names = trigger.include.clone();
305 names.sort();
306 names.dedup();
307 let key = names.join(",");
308 match expanded.get(&key) {
309 Some(v) => v.clone(),
310 None => {
311 let v = fetch_with_includes(ctx, &names)
312 .await
313 .unwrap_or_else(|| api_row.clone());
314 expanded.insert(key, v.clone());
315 v
316 }
317 }
318 }
319 };
320
321 let mut context = serde_json::json!({
322 "entity": entity_value,
323 "operation": lifecycle,
324 });
325 if let Some(prev) = &previous {
327 context["previous"] = prev.clone();
328 }
329 client.publish(&tenant_id, &event_type, context).await;
330 }
331 });
332}
333
334async fn fetch_with_includes(ctx: &EventIncludeCtx, names: &[String]) -> Option<Value> {
337 use crate::service::CrudService;
338 use crate::sql::{FilterNode, IncludeSelect, RsqlOp};
339
340 let selected: Vec<&(String, crate::config::IncludeSpec, ResolvedEntity)> = ctx
341 .resolved
342 .iter()
343 .filter(|(name, _, _)| names.iter().any(|n| n == name))
344 .collect();
345
346 for want in names {
347 if !selected.iter().any(|(name, _, _)| name == want) {
348 tracing::warn!(
349 entity = %ctx.entity.path_segment,
350 include = %want,
351 "event include is not a configured relationship — skipped"
352 );
353 }
354 }
355 if selected.is_empty() {
356 return None;
357 }
358
359 let include_selects: Vec<IncludeSelect> = selected
360 .iter()
361 .map(|(name, spec, related)| IncludeSelect {
362 name: name.as_str(),
363 direction: spec.direction.clone(),
364 related,
365 our_key: spec.our_key_column.as_str(),
366 their_key: spec.their_key_column.as_str(),
367 })
368 .collect();
369
370 let filter = FilterNode::Leaf {
371 field: ctx.pk_column.clone(),
372 op: RsqlOp::Eq,
373 values: vec![ctx.pk_value.clone()],
374 };
375
376 let mut rls_tx = match &ctx.rls_tenant {
378 Some(tenant) => {
379 let mut tx = ctx.pool.begin().await.ok()?;
380 if let Some(sql) = ctx.dialect.set_tenant_session_sql(tenant) {
381 sqlx::query(&sql).execute(&mut *tx).await.ok()?;
382 }
383 Some(tx)
384 }
385 None => None,
386 };
387 let mut executor = match rls_tx.as_mut() {
388 Some(tx) => crate::service::TenantExecutor::conn(tx, ctx.dialect.as_ref()),
389 None => crate::service::TenantExecutor::pool(&ctx.pool, ctx.dialect.as_ref()),
390 };
391
392 let rows = CrudService::list_with_includes(
393 &mut executor,
394 &ctx.entity,
395 Some(&filter),
396 &[],
397 Some(1),
398 None,
399 include_selects.as_slice(),
400 &[],
401 ctx.schema_override.as_deref(),
402 ctx.dialect.as_ref(),
403 None,
404 )
405 .await;
406
407 let mut rows = match rows {
408 Ok(r) => r,
409 Err(e) => {
410 tracing::warn!(
411 entity = %ctx.entity.path_segment,
412 error = %e,
413 "event include fetch failed — publishing the flat row"
414 );
415 return None;
416 }
417 };
418
419 let owned: Vec<(String, crate::config::IncludeSpec, ResolvedEntity)> =
420 selected.into_iter().cloned().collect();
421 crate::handlers::entity::post_process_include_columns(&mut rows, &owned);
422
423 let mut row = rows.into_iter().next()?;
424 crate::handlers::entity::strip_sensitive_columns(&mut row, &ctx.entity.sensitive_columns);
425 for (name, _, related) in &owned {
426 if let Some(nested) = row.get_mut(name) {
428 strip_nested_sensitive(nested, &related.sensitive_columns);
429 }
430 }
431 crate::case::value_keys_to_camel_case(&mut row);
432 Some(row)
433}
434
435fn strip_nested_sensitive(v: &mut Value, sensitive: &std::collections::HashSet<String>) {
436 match v {
437 Value::Array(items) => {
438 for item in items {
439 crate::handlers::entity::strip_sensitive_columns(item, sensitive);
440 }
441 }
442 Value::Object(_) => crate::handlers::entity::strip_sensitive_columns(v, sensitive),
443 _ => {}
444 }
445}