minco_plugin_notifications/
lib.rs1#![forbid(unsafe_code)]
3
4use async_trait::async_trait;
5use chrono::{DateTime, Utc};
6use minco_core::{
7 CapabilityProvision, DataClass, Plugin, PluginContext, PluginDescriptor, PluginError, PluginId,
8 PluginStability,
9};
10use semver::{Version, VersionReq};
11use serde::{Deserialize, Serialize};
12use std::{collections::BTreeMap, sync::Arc};
13use tokio::sync::RwLock;
14use uuid::Uuid;
15
16#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
17#[serde(rename_all = "snake_case")]
18pub enum NotificationChannel {
19 Email,
20 Webhook,
21 InApp,
22 DeveloperInbox,
23 Custom(String),
24}
25
26#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
27pub struct Notification {
28 pub id: Uuid,
29 pub topic: String,
30 pub channel: NotificationChannel,
31 pub recipient: String,
32 pub title: String,
33 pub body: String,
34 #[serde(default, skip_serializing_if = "Option::is_none")]
35 pub link: Option<String>,
36 #[serde(default)]
37 pub metadata: BTreeMap<String, serde_json::Value>,
38 pub created_at: DateTime<Utc>,
39}
40
41impl Notification {
42 pub fn new(
43 topic: impl Into<String>,
44 channel: NotificationChannel,
45 recipient: impl Into<String>,
46 title: impl Into<String>,
47 body: impl Into<String>,
48 ) -> Self {
49 Self {
50 id: Uuid::now_v7(),
51 topic: topic.into(),
52 channel,
53 recipient: recipient.into(),
54 title: title.into(),
55 body: body.into(),
56 link: None,
57 metadata: BTreeMap::new(),
58 created_at: Utc::now(),
59 }
60 }
61}
62
63#[async_trait]
64pub trait NotificationSink: Send + Sync + std::fmt::Debug {
65 async fn send(&self, notification: Notification) -> Result<(), NotificationError>;
66}
67
68#[derive(Clone)]
69pub struct NotificationService(pub Arc<dyn NotificationSink>);
70
71impl std::fmt::Debug for NotificationService {
72 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
73 formatter.debug_tuple("NotificationService").finish()
74 }
75}
76
77impl NotificationService {
78 pub fn new(sink: Arc<dyn NotificationSink>) -> Self {
79 Self(sink)
80 }
81
82 pub async fn send(&self, notification: Notification) -> Result<(), NotificationError> {
83 self.0.send(notification).await
84 }
85}
86
87#[derive(Debug, Default)]
88pub struct MemoryNotificationSink {
89 notifications: RwLock<Vec<Notification>>,
90}
91
92impl MemoryNotificationSink {
93 pub async fn all(&self) -> Vec<Notification> {
94 self.notifications.read().await.clone()
95 }
96}
97
98#[async_trait]
99impl NotificationSink for MemoryNotificationSink {
100 async fn send(&self, notification: Notification) -> Result<(), NotificationError> {
101 if notification.recipient.trim().is_empty() {
102 return Err(NotificationError::InvalidRecipient);
103 }
104 self.notifications.write().await.push(notification);
105 Ok(())
106 }
107}
108
109#[derive(Debug, Clone)]
110pub struct NotificationsPlugin {
111 service: NotificationService,
112}
113
114impl NotificationsPlugin {
115 pub fn new(sink: Arc<dyn NotificationSink>) -> Self {
116 Self {
117 service: NotificationService::new(sink),
118 }
119 }
120
121 pub fn memory() -> (Self, Arc<MemoryNotificationSink>) {
122 let sink = Arc::new(MemoryNotificationSink::default());
123 (Self::new(sink.clone()), sink)
124 }
125}
126
127impl Plugin for NotificationsPlugin {
128 fn descriptor(&self) -> PluginDescriptor {
129 let mut descriptor = PluginDescriptor::new(
130 PluginId::new("notifications").expect("static plugin ID"),
131 Version::new(1, 0, 0),
132 "Provider-neutral email, webhook, in-app, and developer notifications",
133 );
134 descriptor.documentation = Some("https://docs.rs/minco-plugin-notifications".into());
135 descriptor.core_compatibility =
136 VersionReq::parse(concat!("^", env!("CARGO_PKG_VERSION"))).expect("package version");
137 descriptor.stability = PluginStability::Beta;
138 descriptor
139 .data_classes
140 .extend([DataClass::Personal, DataClass::Confidential]);
141 descriptor.provides.push(CapabilityProvision {
142 name: "notifications.send".into(),
143 version: Version::new(1, 0, 0),
144 });
145 descriptor
146 }
147
148 fn install(&self, context: &mut PluginContext<'_>) -> Result<(), PluginError> {
149 context.services().insert(Arc::new(self.service.clone()))?;
150 Ok(())
151 }
152}
153
154#[derive(Debug, thiserror::Error)]
155pub enum NotificationError {
156 #[error("notification recipient must not be empty")]
157 InvalidRecipient,
158 #[error("notification delivery failed: {0}")]
159 Delivery(String),
160}
161
162#[cfg(test)]
163mod tests {
164 use super::*;
165
166 #[tokio::test]
167 async fn memory_sink_keeps_delivery_order() {
168 let sink = MemoryNotificationSink::default();
169 sink.send(Notification::new(
170 "feedback.created",
171 NotificationChannel::DeveloperInbox,
172 "team",
173 "New feedback",
174 "The client reported a problem",
175 ))
176 .await
177 .unwrap();
178 assert_eq!(sink.all().await[0].topic, "feedback.created");
179 }
180}