Files
k-tv/crates/presentation/src/background/webhook_consumer.rs
Gabriel Kaszewski 56d742a74c presentation: HTTP server crate w/ handlers, routes, background tasks
Axum binary that wires all clean-arch crates together:
- AppState holds pre-built Deps structs (auth, channels, schedule, library, etc.)
- JWT extractors (CurrentUser, AdminUser, OptionalCurrentUser)
- Handlers delegate to application use cases, map to api-types DTOs
- Routes: auth, channels, schedule, library, admin, providers, config, iptv
- Background: auto-scheduler, broadcast poller, webhook consumer, library sync
- Factory builds everything from Config + DbPool
- SimpleProviderRegistry impl of IProviderRegistry trait
- NoopMediaProvider fallback
2026-07-12 03:23:20 +02:00

205 lines
6.5 KiB
Rust

//! 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<DomainEvent>,
channel_query: Arc<dyn ChannelQuery>,
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::<serde_json::Map<String, Value>>(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);
}
}
}