Skip to main content

minco_plugin_notifications/
lib.rs

1//! Provider-neutral notifications and a deterministic memory reference sink.
2#![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}