- 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
208 lines
6.6 KiB
Rust
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);
|
|
}
|
|
}
|
|
}
|