Skip to main content

max / makenotwork

8.1 KB · 268 lines History Blame Raw
1 //! Multithreaded forum thread provisioning for published items and blog posts.
2 //!
3 //! Provisioning retries: the scheduler calls the `_by_lookup` entry points on
4 //! every publish sweep for any item or blog post whose `mt_thread_id` is still
5 //! null (`scheduler/mod.rs`), so a failed create is attempted again on the next
6 //! tick. Duplicate threads are prevented on MT's side by `external_ref`, the
7 //! idempotency key, which is `mnw:item:<item_id>` or `mnw:blog:<post_id>`, both
8 //! stable across retries. The thread id is written back with
9 //! `set_mt_thread_id`, which is what ends the retry loop.
10
11 use sqlx::PgPool;
12
13 use crate::Integrations;
14 use crate::background::BackgroundTx;
15 use crate::config::Config;
16 use crate::db;
17 use crate::db::{DbBlogPost, DbItem};
18
19 /// Fire-and-forget: create an MT discussion thread for a published item.
20 /// Called from the item update handler where the user is available.
21 pub fn spawn_mt_thread_for_item(
22 db: &PgPool,
23 bg: &BackgroundTx,
24 integrations: &Integrations,
25 config: &Config,
26 item: &DbItem,
27 user: &crate::auth::SessionUser,
28 ) {
29 let Some(ref mt) = integrations.mt_client else {
30 return;
31 };
32 let mt = mt.clone();
33 let db_pool = db.clone();
34 let host_url = config.host_url.clone();
35 let item_id = item.id;
36 let item_title = item.title.clone();
37 let project_id = item.project_id;
38 let user_id = *user.id;
39 let username = user.username.to_string();
40 let display_name = user.display_name.clone();
41
42 bg.spawn("mt-thread-item", async move {
43 create_mt_thread_for_item(
44 &mt,
45 &db_pool,
46 &host_url,
47 item_id,
48 &item_title,
49 project_id,
50 user_id,
51 &username,
52 display_name.as_deref(),
53 )
54 .await;
55 });
56 }
57
58 /// Fire-and-forget: create an MT discussion thread for a published item
59 /// (scheduler version, looks up the project/user from DB).
60 pub(super) fn spawn_mt_thread_for_item_by_lookup(
61 db: &PgPool,
62 bg: &BackgroundTx,
63 integrations: &Integrations,
64 config: &Config,
65 item: &DbItem,
66 ) {
67 let Some(ref mt) = integrations.mt_client else {
68 return;
69 };
70 let mt = mt.clone();
71 let db_pool = db.clone();
72 let host_url = config.host_url.clone();
73 let item_id = item.id;
74 let item_title = item.title.clone();
75 let project_id = item.project_id;
76
77 bg.spawn("mt-thread-item-lookup", async move {
78 let Ok(Some(project)) = db::projects::get_project_by_id(&db_pool, project_id).await else {
79 return;
80 };
81 let Ok(Some(user)) = db::users::get_user_by_id(&db_pool, project.user_id).await else {
82 return;
83 };
84 create_mt_thread_for_item(
85 &mt,
86 &db_pool,
87 &host_url,
88 item_id,
89 &item_title,
90 project_id,
91 *user.id,
92 &user.username,
93 user.display_name.as_deref(),
94 )
95 .await;
96 });
97 }
98
99 #[allow(clippy::too_many_arguments)]
100 async fn create_mt_thread_for_item(
101 mt: &crate::mt_client::MtClient,
102 db_pool: &sqlx::PgPool,
103 host_url: &str,
104 item_id: db::ItemId,
105 item_title: &str,
106 project_id: db::ProjectId,
107 user_id: uuid::Uuid,
108 username: &str,
109 display_name: Option<&str>,
110 ) {
111 let Ok(Some(project)) = db::projects::get_project_by_id(db_pool, project_id).await else {
112 return;
113 };
114
115 let item_url = format!("{host_url}/i/{item_id}");
116 let body = format!("Discussion for [{item_title}]({item_url})");
117 let external_ref = format!("mnw:item:{item_id}");
118
119 match mt
120 .create_thread(&crate::mt_client::CreateThreadRequest {
121 community_slug: project.slug.to_string(),
122 category_slug: "items".to_string(),
123 title: item_title.to_string(),
124 body_markdown: body,
125 author_mnw_id: user_id,
126 author_username: username.to_string(),
127 author_display_name: display_name.map(String::from),
128 external_ref,
129 })
130 .await
131 {
132 Ok(resp) => {
133 if let Err(e) = db::items::set_mt_thread_id(db_pool, item_id, resp.thread_id).await {
134 tracing::warn!(error = ?e, "failed to store MT thread ID for item");
135 }
136 }
137 Err(e) => tracing::warn!(error = ?e, %item_id, "MT thread creation failed for item"),
138 }
139 }
140
141 /// Fire-and-forget: create an MT discussion thread for a published blog post.
142 /// Called from the blog post handler where the user is available.
143 pub fn spawn_mt_thread_for_blog_post(
144 db: &PgPool,
145 bg: &BackgroundTx,
146 integrations: &Integrations,
147 config: &Config,
148 post: &DbBlogPost,
149 user: &crate::auth::SessionUser,
150 ) {
151 let Some(ref mt) = integrations.mt_client else {
152 return;
153 };
154 let mt = mt.clone();
155 let db_pool = db.clone();
156 let host_url = config.host_url.clone();
157 let post_id = post.id;
158 let post_title = post.title.clone();
159 let post_slug = post.slug.to_string();
160 let project_id = post.project_id;
161 let user_id = *user.id;
162 let username = user.username.to_string();
163 let display_name = user.display_name.clone();
164
165 bg.spawn("mt-thread-blog", async move {
166 create_mt_thread_for_blog_post(
167 &mt,
168 &db_pool,
169 &host_url,
170 post_id,
171 &post_title,
172 &post_slug,
173 project_id,
174 user_id,
175 &username,
176 display_name.as_deref(),
177 )
178 .await;
179 });
180 }
181
182 /// Fire-and-forget: create an MT discussion thread for a published blog post
183 /// (scheduler version, looks up the project/user from DB).
184 pub(super) fn spawn_mt_thread_for_blog_post_by_lookup(
185 db: &PgPool,
186 bg: &BackgroundTx,
187 integrations: &Integrations,
188 config: &Config,
189 post: &DbBlogPost,
190 ) {
191 let Some(ref mt) = integrations.mt_client else {
192 return;
193 };
194 let mt = mt.clone();
195 let db_pool = db.clone();
196 let host_url = config.host_url.clone();
197 let post_id = post.id;
198 let post_title = post.title.clone();
199 let post_slug = post.slug.to_string();
200 let project_id = post.project_id;
201
202 bg.spawn("mt-thread-blog-lookup", async move {
203 let Ok(Some(project)) = db::projects::get_project_by_id(&db_pool, project_id).await else {
204 return;
205 };
206 let Ok(Some(user)) = db::users::get_user_by_id(&db_pool, project.user_id).await else {
207 return;
208 };
209 create_mt_thread_for_blog_post(
210 &mt,
211 &db_pool,
212 &host_url,
213 post_id,
214 &post_title,
215 &post_slug,
216 project_id,
217 *user.id,
218 &user.username,
219 user.display_name.as_deref(),
220 )
221 .await;
222 });
223 }
224
225 #[allow(clippy::too_many_arguments)]
226 async fn create_mt_thread_for_blog_post(
227 mt: &crate::mt_client::MtClient,
228 db_pool: &sqlx::PgPool,
229 host_url: &str,
230 post_id: db::BlogPostId,
231 post_title: &str,
232 post_slug: &str,
233 project_id: db::ProjectId,
234 user_id: uuid::Uuid,
235 username: &str,
236 display_name: Option<&str>,
237 ) {
238 let Ok(Some(project)) = db::projects::get_project_by_id(db_pool, project_id).await else {
239 return;
240 };
241
242 let post_url = format!("{}/{}/blog/{}", host_url, project.slug, post_slug);
243 let body = format!("Discussion for [{post_title}]({post_url})");
244 let external_ref = format!("mnw:blog:{post_id}");
245
246 match mt
247 .create_thread(&crate::mt_client::CreateThreadRequest {
248 community_slug: project.slug.to_string(),
249 category_slug: "blog".to_string(),
250 title: post_title.to_string(),
251 body_markdown: body,
252 author_mnw_id: user_id,
253 author_username: username.to_string(),
254 author_display_name: display_name.map(String::from),
255 external_ref,
256 })
257 .await
258 {
259 Ok(resp) => {
260 if let Err(e) = db::blog_posts::set_mt_thread_id(db_pool, post_id, resp.thread_id).await
261 {
262 tracing::warn!(error = ?e, "failed to store MT thread ID for blog post");
263 }
264 }
265 Err(e) => tracing::warn!(error = ?e, %post_id, "MT thread creation failed for blog post"),
266 }
267 }
268