Skip to main content

nexus_common/models/notification/
mod.rs

1use crate::db::kv::SortOrder;
2use crate::db::{get_neo4j_graph, queries, RedisOps};
3use crate::types::DynError;
4use crate::types::Pagination;
5use chrono::Utc;
6use neo4rs::Row;
7use serde::{Deserialize, Serialize};
8use utoipa::ToSchema;
9
10#[derive(Serialize, Deserialize, ToSchema, Clone, Debug, PartialEq)]
11#[serde(rename_all = "snake_case")]
12pub enum PostChangedSource {
13    Reply,       // A reply to you was deleted/edited.
14    Repost,      // A repost of your post was deleted/edited.
15    Bookmark,    // A post you bookmarked was deleted/edited.
16    ReplyParent, // The parent post of your reply was deleted/edited.
17    RepostEmbed, // The embedded post on your repost was deleted/edited.
18    TaggedPost,  // A post you tagged was deleted/edited.
19}
20
21pub enum PostChangedType {
22    Edited,
23    Deleted,
24}
25
26#[derive(Serialize, Deserialize, ToSchema, Default, Debug)]
27pub struct Notification {
28    pub timestamp: i64,
29    pub body: NotificationBody,
30}
31
32#[derive(Serialize, Deserialize, Clone, ToSchema, Debug)]
33#[serde(tag = "type", rename_all = "snake_case")]
34pub enum NotificationBody {
35    Follow {
36        followed_by: String,
37    },
38    NewFriend {
39        followed_by: String,
40    },
41    LostFriend {
42        unfollowed_by: String,
43    },
44    TagPost {
45        tagged_by: String,
46        tag_label: String,
47        post_uri: String,
48    },
49    TagProfile {
50        tagged_by: String,
51        tag_label: String,
52    },
53    Reply {
54        replied_by: String,
55        parent_post_uri: String,
56        reply_uri: String,
57    },
58    Repost {
59        reposted_by: String,
60        embed_uri: String,
61        repost_uri: String,
62    },
63    Mention {
64        mentioned_by: String,
65        post_uri: String,
66    },
67    PostDeleted {
68        delete_source: PostChangedSource,
69        deleted_by: String,
70        deleted_uri: String,
71        linked_uri: String,
72    },
73    PostEdited {
74        edit_source: PostChangedSource,
75        edited_by: String,
76        edited_uri: String,
77        linked_uri: String,
78    },
79}
80
81type QueryFunction = fn(&str, &str) -> neo4rs::Query;
82type ExtractFunction = Box<dyn Fn(&Row) -> (String, String) + Send>;
83
84impl Default for NotificationBody {
85    fn default() -> Self {
86        NotificationBody::Follow {
87            followed_by: String::new(),
88        }
89    }
90}
91
92impl RedisOps for Notification {}
93
94impl Notification {
95    pub fn new(body: NotificationBody) -> Self {
96        Self {
97            body,
98            timestamp: Utc::now().timestamp_millis(), //milliseconds to avoid sub second collision
99        }
100    }
101
102    /// Stores the `NotificationBody` in the sorted set for the user using the timestamp as the score.
103    async fn put_to_index(&self, user_id: &str) -> Result<(), DynError> {
104        let notification_body_json = serde_json::to_string(&self.body)?;
105        let score = self.timestamp as f64;
106
107        Notification::put_index_sorted_set(
108            &["Notification", user_id],
109            &[(score, notification_body_json.as_str())],
110            None,
111            None,
112        )
113        .await
114    }
115
116    /// Lists notifications from the sorted set for the user, based on skip and limit, or timestamp range.
117    pub async fn get_by_id(user_id: &str, pagination: Pagination) -> Result<Vec<Self>, DynError> {
118        // Set the default params for pagination
119        let skip = pagination.skip.unwrap_or(0);
120        let limit = pagination.limit.unwrap_or(20);
121
122        let notifications = Notification::try_from_index_sorted_set(
123            &["Notification", user_id],
124            pagination.start,
125            pagination.end,
126            Some(skip),
127            Some(limit),
128            SortOrder::Descending, // StreamSorting in descending order by score (timestamp)
129            None,
130        )
131        .await?;
132
133        let mut result = Vec::new();
134
135        if let Some(notifications) = notifications {
136            for (notification_body_str, score) in notifications {
137                if let Ok(body) = serde_json::from_str::<NotificationBody>(&notification_body_str) {
138                    let notification = Notification {
139                        timestamp: score as i64,
140                        body,
141                    };
142                    result.push(notification);
143                }
144            }
145        }
146
147        Ok(result)
148    }
149
150    pub async fn new_follow(
151        user_id: &str,
152        followee_id: &str,
153        new_friend: bool,
154    ) -> Result<(), DynError> {
155        let body = match new_friend {
156            true => NotificationBody::NewFriend {
157                followed_by: user_id.to_string(),
158            },
159            false => NotificationBody::Follow {
160                followed_by: user_id.to_string(),
161            },
162        };
163
164        let notification = Notification::new(body);
165        notification.put_to_index(followee_id).await?;
166
167        Ok(())
168    }
169
170    pub async fn lost_follow(
171        user_id: &str,
172        followee_id: &str,
173        were_friends: bool,
174    ) -> Result<(), DynError> {
175        if !were_friends {
176            return Ok(());
177        }
178
179        let body = NotificationBody::LostFriend {
180            unfollowed_by: user_id.to_string(),
181        };
182        let notification = Notification::new(body);
183        notification.put_to_index(followee_id).await?;
184
185        Ok(())
186    }
187
188    pub async fn new_post_tag(
189        user_id: &str,
190        author_id: &str,
191        label: &str,
192        post_uri: &str,
193    ) -> Result<(), DynError> {
194        if user_id == author_id {
195            return Ok(());
196        }
197        let body = NotificationBody::TagPost {
198            tagged_by: user_id.to_string(),
199            tag_label: label.to_string(),
200            post_uri: post_uri.to_string(),
201        };
202        let notification = Notification::new(body);
203        notification.put_to_index(author_id).await
204    }
205
206    pub async fn new_user_tag(
207        tagger_user_id: &str,
208        tagged_user_id: &str,
209        label: &str,
210    ) -> Result<(), DynError> {
211        if tagger_user_id == tagged_user_id {
212            return Ok(());
213        }
214        let body = NotificationBody::TagProfile {
215            tagged_by: tagger_user_id.to_string(),
216            tag_label: label.to_string(),
217        };
218        let notification = Notification::new(body);
219        notification.put_to_index(tagged_user_id).await
220    }
221
222    pub async fn new_post_reply(
223        user_id: &str,
224        parent_uri: &str,
225        reply_uri: &str,
226        parent_post_author: &str,
227    ) -> Result<(), DynError> {
228        if user_id == parent_post_author {
229            return Ok(());
230        }
231        let body = NotificationBody::Reply {
232            replied_by: user_id.to_string(),
233            parent_post_uri: parent_uri.to_string(),
234            reply_uri: reply_uri.to_string(),
235        };
236        let notification = Notification::new(body);
237        notification.put_to_index(parent_post_author).await
238    }
239
240    pub async fn new_mention(
241        user_id: &str,
242        mentioned_id: &str,
243        post_id: &str,
244    ) -> Result<Option<String>, DynError> {
245        if user_id == mentioned_id {
246            return Ok(None);
247        }
248        let body = NotificationBody::Mention {
249            mentioned_by: user_id.to_string(),
250            post_uri: format!("pubky://{user_id}/pub/pubky.app/posts/{post_id}"),
251        };
252        let notification = Notification::new(body);
253        notification.put_to_index(mentioned_id).await?;
254
255        Ok(Some(mentioned_id.to_string()))
256    }
257
258    pub async fn new_repost(
259        user_id: &str,
260        embed_uri: &str,
261        repost_uri: &str,
262        embed_post_author: &str,
263    ) -> Result<(), DynError> {
264        if user_id == embed_post_author {
265            return Ok(());
266        }
267        let body = NotificationBody::Repost {
268            reposted_by: user_id.to_string(),
269            embed_uri: embed_uri.to_string(),
270            repost_uri: repost_uri.to_string(),
271        };
272        let notification = Notification::new(body);
273        notification.put_to_index(embed_post_author).await
274    }
275
276    pub async fn post_children_changed(
277        user_id: &str,
278        linked_uri: &str,
279        linked_post_author: &str,
280        changed_uri: &str,
281        change_source: PostChangedSource,
282        changed_type: &PostChangedType,
283    ) -> Result<(), DynError> {
284        if user_id == linked_post_author {
285            return Ok(());
286        }
287        let body = match changed_type {
288            PostChangedType::Deleted => NotificationBody::PostDeleted {
289                delete_source: change_source,
290                deleted_by: user_id.to_string(),
291                deleted_uri: changed_uri.to_string(),
292                linked_uri: linked_uri.to_string(),
293            },
294            PostChangedType::Edited => NotificationBody::PostEdited {
295                edit_source: change_source,
296                edited_by: user_id.to_string(),
297                edited_uri: changed_uri.to_string(),
298                linked_uri: linked_uri.to_string(),
299            },
300        };
301        let notification = Notification::new(body);
302        notification.put_to_index(linked_post_author).await
303    }
304
305    // Delete and Edit post notifications to users who interacted
306
307    // A post you replied/reposted/tagged/bookmarked was edited or deleted
308    pub async fn changed_post(
309        author_id: &str,
310        post_id: &str,
311        changed_uri: &str,
312        changed_type: &PostChangedType,
313    ) -> Result<(), DynError> {
314        // Define the notification types and associated data
315        let notification_types: Vec<(QueryFunction, PostChangedSource, ExtractFunction)> = vec![
316            (
317                queries::get::get_post_replies as QueryFunction,
318                PostChangedSource::ReplyParent,
319                Box::new(|row: &Row| {
320                    let replier_id: &str = row.get("replier_id").unwrap_or_default();
321                    let reply_id: &str = row.get("reply_id").unwrap_or_default();
322                    let linked_uri = format!("pubky://{replier_id}/pub/pubky.app/posts/{reply_id}");
323                    (replier_id.to_string(), linked_uri)
324                }),
325            ),
326            (
327                queries::get::get_post_tags as QueryFunction,
328                PostChangedSource::TaggedPost,
329                Box::new(|row: &Row| {
330                    let tagger_id: &str = row.get("tagger_id").unwrap_or_default();
331                    let tag_id: &str = row.get("tag_id").unwrap_or_default();
332                    let linked_uri = format!("pubky://{tagger_id}/pub/pubky.app/tags/{tag_id}");
333                    (tagger_id.to_string(), linked_uri)
334                }),
335            ),
336            (
337                queries::get::get_post_bookmarks as QueryFunction,
338                PostChangedSource::Bookmark,
339                Box::new(|row: &Row| {
340                    let bookmarker_id: &str = row.get("bookmarker_id").unwrap_or_default();
341                    let bookmark_id: &str = row.get("bookmark_id").unwrap_or_default();
342                    let linked_uri =
343                        format!("pubky://{bookmarker_id}/pub/pubky.app/bookmarks/{bookmark_id}");
344                    (bookmarker_id.to_string(), linked_uri)
345                }),
346            ),
347            (
348                queries::get::get_post_reposts as QueryFunction,
349                PostChangedSource::RepostEmbed,
350                Box::new(|row: &Row| {
351                    let reposter_id: &str = row.get("reposter_id").unwrap_or_default();
352                    let repost_id: &str = row.get("repost_id").unwrap_or_default();
353                    let linked_uri =
354                        format!("pubky://{reposter_id}/pub/pubky.app/posts/{repost_id}");
355                    (reposter_id.to_string(), linked_uri)
356                }),
357            ),
358        ];
359
360        for (query_fn, post_changed_source, extract_fn) in notification_types {
361            let mut result;
362            {
363                let graph = get_neo4j_graph()?;
364                let query = query_fn(author_id, post_id);
365
366                let graph = graph.lock().await;
367                result = graph.execute(query).await?;
368            }
369
370            while let Some(row) = result.next().await? {
371                let (user_id, linked_uri) = extract_fn(&row);
372
373                if author_id == user_id {
374                    // Do not notify the author themselves
375                    continue;
376                }
377
378                let notification_body = match changed_type {
379                    PostChangedType::Deleted => NotificationBody::PostDeleted {
380                        delete_source: post_changed_source.clone(),
381                        deleted_by: author_id.to_string(),
382                        deleted_uri: changed_uri.to_string(),
383                        linked_uri,
384                    },
385                    PostChangedType::Edited => NotificationBody::PostEdited {
386                        edit_source: post_changed_source.clone(),
387                        edited_by: author_id.to_string(),
388                        edited_uri: changed_uri.to_string(),
389                        linked_uri,
390                    },
391                };
392
393                let notification = Notification::new(notification_body);
394                notification.put_to_index(&user_id).await?;
395            }
396        }
397        Ok(())
398    }
399}