//! WebhookConsumer background task. //! //! Subscribes to domain events and delivers them to per-channel webhook URLs. use std::sync::Arc; use chrono::Utc; use handlebars::Handlebars; use serde_json::{Value, json}; use tokio::sync::broadcast; use uuid::Uuid; use domain::events::DomainEvent; use domain::ports::ChannelQuery; /// Consumes domain events and delivers them to per-channel webhook URLs. pub async fn run( mut rx: broadcast::Receiver, channel_query: Arc, client: reqwest::Client, ) { loop { match rx.recv().await { Ok(event) => { let channel_id = event_channel_id(&event); let payload = build_payload(&event); let channel_id_vo = domain::ChannelId::from(channel_id); match channel_query.find_by_id(channel_id_vo).await { Ok(Some(channel)) => { if let Some(url) = channel.webhook_url() { let url = url.to_string(); let client = client.clone(); let template = channel.webhook_body_template().map(|s| s.to_string()); let headers = channel.webhook_headers().map(|s| s.to_string()); tokio::spawn(async move { post_webhook( &client, &url, payload, template.as_deref(), headers.as_deref(), ) .await; }); } } Ok(None) => {} Err(e) => { tracing::warn!( "webhook consumer: failed to look up channel {}: {}", channel_id, e ); } } } Err(broadcast::error::RecvError::Lagged(n)) => { tracing::warn!("webhook consumer lagged, {} events dropped", n); } Err(broadcast::error::RecvError::Closed) => { tracing::info!("webhook consumer: event bus closed, shutting down"); break; } } } } fn event_channel_id(event: &DomainEvent) -> Uuid { match event { DomainEvent::BroadcastTransition { channel_id, .. } => channel_id.value(), DomainEvent::NoSignal { channel_id } => channel_id.value(), DomainEvent::ScheduleGenerated { channel_id, .. } => channel_id.value(), DomainEvent::ChannelCreated { channel_id } => channel_id.value(), DomainEvent::ChannelUpdated { channel_id } => channel_id.value(), DomainEvent::ChannelDeleted { channel_id } => channel_id.value(), _ => Uuid::nil(), } } fn build_payload(event: &DomainEvent) -> Value { let now = Utc::now().to_rfc3339(); let channel_id = event_channel_id(event); match event { DomainEvent::BroadcastTransition { slot_id, .. } => { json!({ "event": "broadcast_transition", "timestamp": now, "channel_id": channel_id, "data": { "slot_id": slot_id.value(), } }) } DomainEvent::NoSignal { .. } => { json!({ "event": "no_signal", "timestamp": now, "channel_id": channel_id, "data": {} }) } DomainEvent::ScheduleGenerated { schedule_id, .. } => { json!({ "event": "schedule_generated", "timestamp": now, "channel_id": channel_id, "data": { "schedule_id": schedule_id.value(), } }) } DomainEvent::ChannelCreated { .. } => { json!({ "event": "channel_created", "timestamp": now, "channel_id": channel_id, "data": {} }) } DomainEvent::ChannelUpdated { .. } => { json!({ "event": "channel_updated", "timestamp": now, "channel_id": channel_id, "data": {} }) } DomainEvent::ChannelDeleted { .. } => { json!({ "event": "channel_deleted", "timestamp": now, "channel_id": channel_id, "data": {} }) } _ => { json!({ "event": "unknown", "timestamp": now, "channel_id": channel_id, "data": {} }) } } } async fn post_webhook( client: &reqwest::Client, url: &str, payload: Value, template: Option<&str>, headers_json: Option<&str>, ) { let body = if let Some(tmpl) = template { let hbs = Handlebars::new(); match hbs.render_template(tmpl, &payload) { Ok(rendered) => rendered, Err(e) => { tracing::warn!("webhook template render failed for {}: {}", url, e); return; } } } else { match serde_json::to_string(&payload) { Ok(s) => s, Err(e) => { tracing::warn!("webhook payload serialize failed: {}", e); return; } } }; let mut req = client.post(url).body(body); let mut has_content_type = false; if let Some(h) = headers_json { if let Ok(map) = serde_json::from_str::>(h) { for (k, v) in &map { if k.to_lowercase() == "content-type" { has_content_type = true; } if let Some(v_str) = v.as_str() { req = req.header(k.as_str(), v_str); } } } } if !has_content_type { req = req.header("Content-Type", "application/json"); } match req.send().await { Ok(resp) => { if !resp.status().is_success() { tracing::warn!("webhook POST to {} returned status {}", url, resp.status()); } } Err(e) => { tracing::warn!("webhook POST to {} failed: {}", url, e); } } }