Files
k-tv/crates/presentation/src/background/webhook_consumer.rs
Gabriel Kaszewski 826e824b58 replace tokio::broadcast event bus with SQLite-backed event queue (ADR-0003)
- DomainEvent: add Serialize/Deserialize
- EventConsumer port: poll_next/ack/nack replacing recv()
- EventEnvelope: wraps event with id/retry_count/created_at
- SqliteEventPublisher/SqliteEventConsumer: INSERT/poll/DLQ
- migration: event_queue + dead_letter_queue tables
- InMemoryEventBus: combined publisher+consumer test double
- NoopEventConsumer: test stub
- webhook_consumer: polls EventConsumer instead of broadcast::Receiver
- presentation+mcp wiring: create from SqlitePool
2026-07-12 07:23:21 +02:00

208 lines
6.6 KiB
Rust

use std::sync::Arc;
use chrono::Utc;
use handlebars::Handlebars;
use serde_json::{Value, json};
use uuid::Uuid;
use domain::events::DomainEvent;
use domain::ports::{ChannelQuery, EventConsumer};
const DEFAULT_CONTENT_TYPE: &str = "application/json";
const POLL_INTERVAL: std::time::Duration = std::time::Duration::from_secs(1);
pub async fn run(
consumer: Arc<dyn EventConsumer>,
channel_query: Arc<dyn ChannelQuery>,
client: reqwest::Client,
) {
loop {
match consumer.poll_next().await {
Ok(Some(envelope)) => {
let event_id = envelope.id();
let event = envelope.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
);
}
}
if let Err(e) = consumer.ack(event_id).await {
tracing::warn!("webhook consumer: ack failed for event {}: {}", event_id, e);
}
}
Ok(None) => {
tokio::time::sleep(POLL_INTERVAL).await;
}
Err(e) => {
tracing::warn!("webhook consumer: poll error: {}", e);
tokio::time::sleep(POLL_INTERVAL).await;
}
}
}
}
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.eq_ignore_ascii_case("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", DEFAULT_CONTENT_TYPE);
}
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);
}
}
}