1use std::collections::HashMap;
30use std::sync::Arc;
31
32use async_trait::async_trait;
33use serde::{Deserialize, Serialize};
34
35#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
38pub enum Block {
39 Heading(String),
41 Text(String),
43 Fields(Vec<(String, String)>),
45 Divider,
47 Action { label: String, url: String },
49}
50
51impl Block {
52 pub fn heading(s: impl Into<String>) -> Self {
53 Block::Heading(s.into())
54 }
55 pub fn text(s: impl Into<String>) -> Self {
56 Block::Text(s.into())
57 }
58 pub fn fields(kv: Vec<(String, String)>) -> Self {
59 Block::Fields(kv)
60 }
61 pub fn action(label: impl Into<String>, url: impl Into<String>) -> Self {
62 Block::Action {
63 label: label.into(),
64 url: url.into(),
65 }
66 }
67}
68
69#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
71pub struct Notification {
72 pub title: Option<String>,
73 pub blocks: Vec<Block>,
74}
75
76impl Notification {
77 pub fn new() -> Self {
78 Self::default()
79 }
80
81 pub fn title(mut self, t: impl Into<String>) -> Self {
82 self.title = Some(t.into());
83 self
84 }
85
86 pub fn block(mut self, b: Block) -> Self {
87 self.blocks.push(b);
88 self
89 }
90
91 pub fn to_plain_text(&self) -> String {
93 let mut out = String::new();
94 if let Some(t) = &self.title {
95 out.push_str(t);
96 out.push_str("\n\n");
97 }
98 for block in &self.blocks {
99 match block {
100 Block::Heading(h) => {
101 out.push_str(h);
102 out.push('\n');
103 }
104 Block::Text(t) => {
105 out.push_str(t);
106 out.push('\n');
107 }
108 Block::Fields(kv) => {
109 for (k, v) in kv {
110 out.push_str(&format!("{k}: {v}\n"));
111 }
112 }
113 Block::Divider => out.push_str("---\n"),
114 Block::Action { label, url } => out.push_str(&format!("{label}: {url}\n")),
115 }
116 }
117 out.trim_end().to_string()
118 }
119}
120
121#[derive(Debug, thiserror::Error)]
122pub enum NotifyError {
123 #[error("no sender registered for channel '{0}'")]
124 UnknownChannel(String),
125 #[error("transport '{transport}' failed: {reason}")]
126 Transport { transport: String, reason: String },
127}
128
129#[async_trait]
132pub trait Sender: Send + Sync {
133 fn name(&self) -> &str;
134 async fn send(&self, recipient: &str, notif: &Notification) -> Result<(), NotifyError>;
136}
137
138#[derive(Debug, Clone, PartialEq)]
139pub struct OutboxItem {
140 pub id: String,
141 pub tenant_id: String,
142 pub subject_id: String,
143 pub channel: String,
144 pub recipient: String,
145 pub notification: Notification,
146 pub attempts: u32,
147 pub lease_version: i64,
148}
149
150#[derive(Debug, Clone, PartialEq)]
151pub struct NewOutboxItem {
152 pub tenant_id: String,
153 pub subject_id: String,
154 pub idempotency_key: String,
155 pub channel: String,
156 pub recipient: String,
157 pub notification: Notification,
158}
159
160#[async_trait]
161pub trait DurableOutbox: Send + Sync {
162 async fn enqueue(&self, item: NewOutboxItem) -> Result<String, String>;
163 async fn claim(
164 &self,
165 worker_id: &str,
166 lease_secs: i64,
167 batch: usize,
168 ) -> Result<Vec<OutboxItem>, String>;
169 async fn mark_sent(&self, id: &str, lease_version: i64) -> Result<(), String>;
170 async fn retry(
171 &self,
172 id: &str,
173 lease_version: i64,
174 error: &str,
175 delay_secs: i64,
176 ) -> Result<(), String>;
177}
178
179#[derive(Default, Clone)]
181pub struct Dispatcher {
182 senders: HashMap<String, Arc<dyn Sender>>,
183}
184
185impl Dispatcher {
186 pub fn new() -> Self {
187 Self::default()
188 }
189
190 pub fn register(&mut self, sender: Arc<dyn Sender>) -> &mut Self {
191 self.senders.insert(sender.name().to_string(), sender);
192 self
193 }
194
195 pub fn channels(&self) -> impl Iterator<Item = &str> {
196 self.senders.keys().map(|s| s.as_str())
197 }
198
199 pub async fn dispatch(
201 &self,
202 channel: &str,
203 recipient: &str,
204 notif: &Notification,
205 ) -> Result<(), NotifyError> {
206 let sender = self
207 .senders
208 .get(channel)
209 .ok_or_else(|| NotifyError::UnknownChannel(channel.to_string()))?;
210 sender.send(recipient, notif).await
211 }
212
213 pub async fn drain(
214 &self,
215 outbox: &dyn DurableOutbox,
216 worker_id: &str,
217 lease_secs: i64,
218 batch: usize,
219 retry_delay_secs: i64,
220 ) -> Result<usize, String> {
221 let items = outbox
222 .claim(worker_id, lease_secs, batch.clamp(1, 100))
223 .await?;
224 for item in &items {
225 match self
226 .dispatch(&item.channel, &item.recipient, &item.notification)
227 .await
228 {
229 Ok(()) => outbox.mark_sent(&item.id, item.lease_version).await?,
230 Err(error) => {
231 outbox
232 .retry(
233 &item.id,
234 item.lease_version,
235 &error.to_string(),
236 retry_delay_secs,
237 )
238 .await?
239 }
240 }
241 }
242 Ok(items.len())
243 }
244}
245
246pub struct LogSender;
249
250#[async_trait]
251impl Sender for LogSender {
252 fn name(&self) -> &str {
253 "log"
254 }
255 async fn send(&self, recipient: &str, notif: &Notification) -> Result<(), NotifyError> {
256 tracing::info!(target: "notify", recipient, body = %notif.to_plain_text(), "notification");
257 Ok(())
258 }
259}
260
261#[cfg(test)]
262mod tests {
263 use super::*;
264 use std::sync::Mutex;
265
266 #[derive(Default)]
267 struct MemoryOutbox {
268 items: Mutex<Vec<OutboxItem>>,
269 sent: Mutex<Vec<String>>,
270 }
271
272 #[async_trait]
273 impl DurableOutbox for MemoryOutbox {
274 async fn enqueue(&self, item: NewOutboxItem) -> Result<String, String> {
275 let id = item.idempotency_key.clone();
276 self.items.lock().unwrap().push(OutboxItem {
277 id: id.clone(),
278 tenant_id: item.tenant_id,
279 subject_id: item.subject_id,
280 channel: item.channel,
281 recipient: item.recipient,
282 notification: item.notification,
283 attempts: 0,
284 lease_version: 1,
285 });
286 Ok(id)
287 }
288 async fn claim(&self, _: &str, _: i64, _: usize) -> Result<Vec<OutboxItem>, String> {
289 Ok(self.items.lock().unwrap().clone())
290 }
291 async fn mark_sent(&self, id: &str, _: i64) -> Result<(), String> {
292 self.sent.lock().unwrap().push(id.into());
293 Ok(())
294 }
295 async fn retry(&self, _: &str, _: i64, _: &str, _: i64) -> Result<(), String> {
296 Ok(())
297 }
298 }
299
300 #[test]
301 fn renders_plain_text() {
302 let n = Notification::new()
303 .title("Run finished")
304 .block(Block::heading("Summary"))
305 .block(Block::text("All good."))
306 .block(Block::fields(vec![("duration".into(), "1.2s".into())]))
307 .block(Block::action("View", "https://x/y"));
308 let txt = n.to_plain_text();
309 assert!(txt.starts_with("Run finished"));
310 assert!(txt.contains("duration: 1.2s"));
311 assert!(txt.contains("View: https://x/y"));
312 }
313
314 struct CapturingSender(Mutex<Vec<String>>);
315 #[async_trait]
316 impl Sender for CapturingSender {
317 fn name(&self) -> &str {
318 "capture"
319 }
320 async fn send(&self, recipient: &str, notif: &Notification) -> Result<(), NotifyError> {
321 self.0
322 .lock()
323 .unwrap()
324 .push(format!("{recipient}|{}", notif.to_plain_text()));
325 Ok(())
326 }
327 }
328
329 #[tokio::test]
330 async fn dispatch_routes_to_named_sender() {
331 let sender = Arc::new(CapturingSender(Mutex::new(Vec::new())));
332 let mut d = Dispatcher::new();
333 d.register(sender.clone());
334
335 let n = Notification::new().block(Block::text("hi"));
336 d.dispatch("capture", "user1", &n).await.unwrap();
337
338 assert_eq!(sender.0.lock().unwrap().len(), 1);
339 assert!(sender.0.lock().unwrap()[0].starts_with("user1|hi"));
340 }
341
342 #[tokio::test]
343 async fn unknown_channel_errors() {
344 let d = Dispatcher::new();
345 let n = Notification::new();
346 assert!(matches!(
347 d.dispatch("nope", "x", &n).await,
348 Err(NotifyError::UnknownChannel(_))
349 ));
350 }
351
352 #[tokio::test]
353 async fn durable_dispatch_marks_claimed_messages_sent() {
354 let sender = Arc::new(CapturingSender(Mutex::new(Vec::new())));
355 let mut dispatcher = Dispatcher::new();
356 dispatcher.register(sender);
357 let outbox = MemoryOutbox::default();
358 outbox
359 .enqueue(NewOutboxItem {
360 tenant_id: "tenant".into(),
361 subject_id: "subject".into(),
362 idempotency_key: "one".into(),
363 channel: "capture".into(),
364 recipient: "recipient".into(),
365 notification: Notification::new().block(Block::text("hello")),
366 })
367 .await
368 .unwrap();
369 assert_eq!(
370 dispatcher
371 .drain(&outbox, "worker", 30, 10, 5)
372 .await
373 .unwrap(),
374 1
375 );
376 assert_eq!(&*outbox.sent.lock().unwrap(), &["one"]);
377 }
378}