Skip to main content

max / makenotwork

8.1 KB · 229 lines History Blame Raw
1 //! Follow/unfollow API endpoints.
2
3 use axum::{
4 extract::{Path, State},
5 response::{Html, IntoResponse, Response},
6 };
7 use uuid::Uuid;
8
9 use crate::background::BackgroundTx;
10 use crate::config::Config;
11 use crate::email::EmailClient;
12 use sqlx::PgPool;
13
14 use crate::{
15 auth::AuthUser,
16 db::{self, FollowTargetType, UserId},
17 error::{AppError, Result},
18 templates::TagFollowToggleTemplate,
19 };
20
21 /// POST /api/follow/{target_type}/{target_id}: follow a user or project.
22 #[tracing::instrument(skip_all, name = "follows::follow_target")]
23 pub(super) async fn follow_target(
24 State(db): State<PgPool>,
25 State(config): State<Config>,
26 State(email): State<EmailClient>,
27 State(bg): State<BackgroundTx>,
28 AuthUser(user): AuthUser,
29 Path((target_type_str, target_id)): Path<(String, Uuid)>,
30 ) -> Result<Response> {
31 user.check_not_suspended()?;
32 user.check_not_sandbox()?;
33 let target_type: FollowTargetType = target_type_str
34 .parse()
35 .map_err(|_| AppError::BadRequest("Invalid target type".to_string()))?;
36
37 validate_target(&db, target_type, target_id, user.id).await?;
38
39 db::follows::follow(&db, user.id, target_type, target_id).await?;
40
41 // Subscribe to project content mailing list (non-critical)
42 if target_type == FollowTargetType::Project
43 && let Err(e) = db::mailing_lists::subscribe_to_content_list(
44 &db,
45 db::ProjectId::from(target_id),
46 user.id,
47 )
48 .await
49 {
50 tracing::warn!(project_id = %target_id, error = ?e, "failed to subscribe to content mailing list");
51 }
52
53 // Send follower notification (fire-and-forget, non-critical)
54 match target_type {
55 FollowTargetType::Tag => {} // no notification for tag follows
56 FollowTargetType::User => {
57 if let Ok(Some(target_user)) =
58 db::users::get_user_by_id(&db, UserId::from(target_id)).await
59 {
60 let follower_user = db::users::get_user_by_id(&db, user.id).await.ok().flatten();
61 let follower_username = follower_user
62 .as_ref()
63 .map_or_else(|| "Someone".to_string(), |u| u.username.to_string());
64 let target_user_id = target_user.id;
65 let target_email = target_user.email.clone();
66 let target_name = target_user.display_name.clone();
67 let unsub_url = crate::email::generate_unsubscribe_url(
68 &config.host_url,
69 target_user.id,
70 crate::email::UnsubscribeAction::Follower,
71 &target_user.id.to_string(),
72 &config.signing_secret,
73 );
74 let email = email.clone();
75 bg.spawn("follower notification", async move {
76 if let Err(e) = email
77 .send_follower_notification(
78 target_user_id,
79 &target_email,
80 target_name.as_deref(),
81 &follower_username,
82 "you",
83 Some(&unsub_url),
84 )
85 .await
86 {
87 tracing::error!(error = ?e, "failed to send follower notification");
88 }
89 });
90 }
91 }
92 FollowTargetType::Project => {
93 if let Ok(Some(project)) =
94 db::projects::get_project_by_id(&db, db::ProjectId::from(target_id)).await
95 && let Ok(Some(owner)) = db::users::get_user_by_id(&db, project.user_id).await
96 {
97 let follower_user = db::users::get_user_by_id(&db, user.id).await.ok().flatten();
98 let follower_username = follower_user
99 .as_ref()
100 .map_or_else(|| "Someone".to_string(), |u| u.username.to_string());
101 let context = format!("your project {}", project.title);
102 let owner_user_id = owner.id;
103 let owner_email = owner.email.clone();
104 let owner_name = owner.display_name.clone();
105 let unsub_url = crate::email::generate_unsubscribe_url(
106 &config.host_url,
107 owner.id,
108 crate::email::UnsubscribeAction::Follower,
109 &owner.id.to_string(),
110 &config.signing_secret,
111 );
112 let email = email.clone();
113 bg.spawn("follower notification", async move {
114 if let Err(e) = email
115 .send_follower_notification(
116 owner_user_id,
117 &owner_email,
118 owner_name.as_deref(),
119 &follower_username,
120 &context,
121 Some(&unsub_url),
122 )
123 .await
124 {
125 tracing::error!(error = ?e, "failed to send follower notification");
126 }
127 });
128 }
129 }
130 }
131
132 if target_type == FollowTargetType::Tag {
133 return Ok(TagFollowToggleTemplate {
134 tag_id: target_id.to_string(),
135 is_following: true,
136 }
137 .into_response());
138 }
139
140 let count = db::follows::get_follower_count(&db, target_type, target_id).await?;
141
142 Ok(Html(crate::quasi::follow::answered(
143 &target_type.to_string(),
144 &target_id.to_string(),
145 true,
146 count,
147 ))
148 .into_response())
149 }
150
151 /// DELETE /api/follow/{target_type}/{target_id}: unfollow a user or project.
152 #[tracing::instrument(skip_all, name = "follows::unfollow_target")]
153 pub(super) async fn unfollow_target(
154 State(db): State<PgPool>,
155 AuthUser(user): AuthUser,
156 Path((target_type_str, target_id)): Path<(String, Uuid)>,
157 ) -> Result<Response> {
158 user.check_not_suspended()?;
159 let target_type: FollowTargetType = target_type_str
160 .parse()
161 .map_err(|_| AppError::BadRequest("Invalid target type".to_string()))?;
162
163 validate_target(&db, target_type, target_id, user.id).await?;
164
165 db::follows::unfollow(&db, user.id, target_type, target_id).await?;
166
167 // Unsubscribe from all project mailing lists (non-critical)
168 if target_type == FollowTargetType::Project
169 && let Err(e) = db::mailing_lists::unsubscribe_from_project(
170 &db,
171 db::ProjectId::from(target_id),
172 user.id,
173 )
174 .await
175 {
176 tracing::warn!(project_id = %target_id, error = ?e, "failed to unsubscribe from project mailing lists");
177 }
178
179 if target_type == FollowTargetType::Tag {
180 return Ok(TagFollowToggleTemplate {
181 tag_id: target_id.to_string(),
182 is_following: false,
183 }
184 .into_response());
185 }
186
187 let count = db::follows::get_follower_count(&db, target_type, target_id).await?;
188
189 Ok(Html(crate::quasi::follow::answered(
190 &target_type.to_string(),
191 &target_id.to_string(),
192 false,
193 count,
194 ))
195 .into_response())
196 }
197
198 /// Validate that the target exists and prevent self-following for user targets.
199 /// Project self-following is allowed (creators may want to follow their own project feed).
200 async fn validate_target(
201 db: &PgPool,
202 target_type: FollowTargetType,
203 target_id: Uuid,
204 follower_id: UserId,
205 ) -> Result<()> {
206 match target_type {
207 FollowTargetType::User => {
208 if *follower_id == target_id {
209 return Err(AppError::BadRequest("Cannot follow yourself".to_string()));
210 }
211 db::users::get_user_by_id(db, UserId::from(target_id))
212 .await?
213 .ok_or(AppError::NotFound)?;
214 }
215 FollowTargetType::Project => {
216 db::projects::get_project_by_id(db, db::ProjectId::from(target_id))
217 .await?
218 .ok_or(AppError::NotFound)?;
219 }
220 FollowTargetType::Tag => {
221 db::tags::get_tag_by_id(db, db::TagId::from(target_id))
222 .await?
223 .ok_or(AppError::NotFound)?;
224 }
225 }
226
227 Ok(())
228 }
229