Skip to main content

nexus_common/models/post/
details.rs

1use super::{PostRelationships, PostStream};
2use crate::db::{exec_single_row, execute_graph_operation, OperationOutcome};
3use crate::db::{get_neo4j_graph, queries, RedisOps};
4use crate::types::DynError;
5use chrono::Utc;
6use pubky_app_specs::{PubkyAppPost, PubkyAppPostKind, PubkyId};
7use serde::{Deserialize, Serialize};
8use utoipa::ToSchema;
9
10/// Represents post data with content, bio, image, links, and status.
11#[derive(Serialize, Deserialize, ToSchema, Default, Debug)]
12// NOTE: Might not be necessary the default values for serde because before PUT a PostDetails node
13// we do sanity check
14pub struct PostDetails {
15    pub content: String,
16    pub id: String,
17    pub indexed_at: i64,
18    pub author: String,
19    pub kind: PubkyAppPostKind,
20    pub uri: String,
21    pub attachments: Option<Vec<String>>,
22}
23
24impl RedisOps for PostDetails {}
25
26impl PostDetails {
27    /// Retrieves post details by author ID and post ID, first trying to get from Redis, then from Neo4j if not found.
28    pub async fn get_by_id(
29        author_id: &str,
30        post_id: &str,
31    ) -> Result<Option<PostDetails>, DynError> {
32        match Self::get_from_index(author_id, post_id).await? {
33            Some(details) => Ok(Some(details)),
34            None => {
35                let graph_response = Self::get_from_graph(author_id, post_id).await?;
36                if let Some((post_details, reply)) = graph_response {
37                    post_details.put_to_index(author_id, reply, false).await?;
38                    return Ok(Some(post_details));
39                }
40                Ok(None)
41            }
42        }
43    }
44
45    pub async fn get_from_index(
46        author_id: &str,
47        post_id: &str,
48    ) -> Result<Option<PostDetails>, DynError> {
49        if let Some(post_details) = Self::try_from_index_json(&[author_id, post_id], None).await? {
50            return Ok(Some(post_details));
51        }
52        Ok(None)
53    }
54
55    /// Retrieves the post fields from Neo4j.
56    pub async fn get_from_graph(
57        author_id: &str,
58        post_id: &str,
59    ) -> Result<Option<(PostDetails, Option<(String, String)>)>, DynError> {
60        let mut result;
61        {
62            let graph = get_neo4j_graph()?;
63            let query = queries::get::get_post_by_id(author_id, post_id);
64
65            let graph = graph.lock().await;
66            result = graph.execute(query).await?;
67        }
68
69        match result.next().await? {
70            Some(row) => {
71                let post: PostDetails = row.get("details")?;
72                let reply_value: Vec<(String, String)> = row.get("reply").unwrap_or(Vec::new());
73                let reply_key = match reply_value.is_empty() {
74                    true => None,
75                    false => Some(reply_value[0].clone()),
76                };
77                Ok(Some((post, reply_key)))
78            }
79            None => Ok(None),
80        }
81    }
82
83    pub async fn put_to_index(
84        &self,
85        author_id: &str,
86        parent_key_wrapper: Option<(String, String)>,
87        is_edit: bool,
88    ) -> Result<(), DynError> {
89        self.put_index_json(&[author_id, &self.id], None, None)
90            .await?;
91        // When we delete a post that has ancestor, ignore other index updates
92        if is_edit {
93            return Ok(());
94        }
95        // The replies are not indexed in the global feeds so we will ignore that indexing
96        match parent_key_wrapper {
97            None => {
98                PostStream::add_to_timeline_sorted_set(self).await?;
99                PostStream::add_to_per_user_sorted_set(self).await?;
100            }
101            Some((parent_author_id, parent_post_id)) => {
102                PostStream::add_to_post_reply_sorted_set(
103                    &[&parent_author_id, &parent_post_id],
104                    author_id,
105                    &self.id,
106                    self.indexed_at,
107                )
108                .await?;
109                PostStream::add_to_replies_per_user_sorted_set(self).await?;
110            }
111        }
112        Ok(())
113    }
114
115    pub async fn from_homeserver(
116        homeserver_post: PubkyAppPost,
117        author_id: &PubkyId,
118        post_id: &String,
119    ) -> Result<Self, DynError> {
120        Ok(PostDetails {
121            uri: format!("pubky://{author_id}/pub/pubky.app/posts/{post_id}"),
122            content: homeserver_post.content,
123            id: post_id.clone(),
124            indexed_at: Utc::now().timestamp_millis(),
125            author: author_id.to_string(),
126            kind: homeserver_post.kind,
127            attachments: homeserver_post.attachments,
128        })
129    }
130
131    pub async fn reindex(author_id: &str, post_id: &str) -> Result<(), DynError> {
132        match Self::get_from_graph(author_id, post_id).await? {
133            Some((details, reply)) => details.put_to_index(author_id, reply, false).await?,
134            None => tracing::error!(
135                "{}:{} Could not found post counts in the graph",
136                author_id,
137                post_id
138            ),
139        }
140        Ok(())
141    }
142
143    // Save new graph node
144    pub async fn put_to_graph(
145        &self,
146        post_relationships: &PostRelationships,
147    ) -> Result<OperationOutcome, DynError> {
148        match queries::put::create_post(self, post_relationships) {
149            Ok(query) => execute_graph_operation(query).await,
150            Err(_) => Err("QUERY: Error while creating the query".into()),
151        }
152    }
153
154    pub async fn delete(
155        author_id: &str,
156        post_id: &str,
157        parent_post_key_wrapper: Option<[String; 2]>,
158    ) -> Result<(), DynError> {
159        // Delete user_details on Redis
160        Self::remove_from_index_multiple_json(&[&[author_id, post_id]]).await?;
161        // Delete post graph node
162        exec_single_row(queries::del::delete_post(author_id, post_id)).await?;
163        // The replies are not indexed in the global feeds
164        match parent_post_key_wrapper {
165            None => {
166                PostStream::remove_from_timeline_sorted_set(author_id, post_id).await?;
167                PostStream::remove_from_per_user_sorted_set(author_id, post_id).await?;
168            }
169            Some([parent_author_id, parent_post_id]) => {
170                PostStream::remove_from_post_reply_sorted_set(
171                    &[&parent_author_id, &parent_post_id],
172                    author_id,
173                    post_id,
174                )
175                .await?;
176                PostStream::remove_from_replies_per_user_sorted_set(author_id, post_id).await?;
177            }
178        }
179        Ok(())
180    }
181}