Skip to main content

max / makenotwork

8.1 KB · 227 lines History Blame Raw
1 //! Follow/unfollow API endpoints.
2
3 use axum::{
4 extract::{Path, State},
5 response::{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::{FollowButtonTemplate, 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 && target_user.notify_follower
60 {
61 let follower_user = db::users::get_user_by_id(&db, user.id).await.ok().flatten();
62 let follower_username = follower_user
63 .as_ref()
64 .map_or_else(|| "Someone".to_string(), |u| u.username.to_string());
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_email,
79 target_name.as_deref(),
80 &follower_username,
81 "you",
82 Some(&unsub_url),
83 )
84 .await
85 {
86 tracing::error!(error = ?e, "failed to send follower notification");
87 }
88 });
89 }
90 }
91 FollowTargetType::Project => {
92 if let Ok(Some(project)) =
93 db::projects::get_project_by_id(&db, db::ProjectId::from(target_id)).await
94 && let Ok(Some(owner)) = db::users::get_user_by_id(&db, project.user_id).await
95 && owner.notify_follower
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_email = owner.email.clone();
103 let owner_name = owner.display_name.clone();
104 let unsub_url = crate::email::generate_unsubscribe_url(
105 &config.host_url,
106 owner.id,
107 crate::email::UnsubscribeAction::Follower,
108 &owner.id.to_string(),
109 &config.signing_secret,
110 );
111 let email = email.clone();
112 bg.spawn("follower notification", async move {
113 if let Err(e) = email
114 .send_follower_notification(
115 &owner_email,
116 owner_name.as_deref(),
117 &follower_username,
118 &context,
119 Some(&unsub_url),
120 )
121 .await
122 {
123 tracing::error!(error = ?e, "failed to send follower notification");
124 }
125 });
126 }
127 }
128 }
129
130 if target_type == FollowTargetType::Tag {
131 return Ok(TagFollowToggleTemplate {
132 tag_id: target_id.to_string(),
133 is_following: true,
134 }
135 .into_response());
136 }
137
138 let count = db::follows::get_follower_count(&db, target_type, target_id).await?;
139
140 Ok(FollowButtonTemplate {
141 target_type: target_type.to_string(),
142 target_id: target_id.to_string(),
143 is_following: true,
144 follower_count: count,
145 }
146 .into_response())
147 }
148
149 /// DELETE /api/follow/{target_type}/{target_id}: unfollow a user or project.
150 #[tracing::instrument(skip_all, name = "follows::unfollow_target")]
151 pub(super) async fn unfollow_target(
152 State(db): State<PgPool>,
153 AuthUser(user): AuthUser,
154 Path((target_type_str, target_id)): Path<(String, Uuid)>,
155 ) -> Result<Response> {
156 user.check_not_suspended()?;
157 let target_type: FollowTargetType = target_type_str
158 .parse()
159 .map_err(|_| AppError::BadRequest("Invalid target type".to_string()))?;
160
161 validate_target(&db, target_type, target_id, user.id).await?;
162
163 db::follows::unfollow(&db, user.id, target_type, target_id).await?;
164
165 // Unsubscribe from all project mailing lists (non-critical)
166 if target_type == FollowTargetType::Project
167 && let Err(e) = db::mailing_lists::unsubscribe_from_project(
168 &db,
169 db::ProjectId::from(target_id),
170 user.id,
171 )
172 .await
173 {
174 tracing::warn!(project_id = %target_id, error = ?e, "failed to unsubscribe from project mailing lists");
175 }
176
177 if target_type == FollowTargetType::Tag {
178 return Ok(TagFollowToggleTemplate {
179 tag_id: target_id.to_string(),
180 is_following: false,
181 }
182 .into_response());
183 }
184
185 let count = db::follows::get_follower_count(&db, target_type, target_id).await?;
186
187 Ok(FollowButtonTemplate {
188 target_type: target_type.to_string(),
189 target_id: target_id.to_string(),
190 is_following: false,
191 follower_count: count,
192 }
193 .into_response())
194 }
195
196 /// Validate that the target exists and prevent self-following for user targets.
197 /// Project self-following is allowed (creators may want to follow their own project feed).
198 async fn validate_target(
199 db: &PgPool,
200 target_type: FollowTargetType,
201 target_id: Uuid,
202 follower_id: UserId,
203 ) -> Result<()> {
204 match target_type {
205 FollowTargetType::User => {
206 if *follower_id == target_id {
207 return Err(AppError::BadRequest("Cannot follow yourself".to_string()));
208 }
209 db::users::get_user_by_id(db, UserId::from(target_id))
210 .await?
211 .ok_or(AppError::NotFound)?;
212 }
213 FollowTargetType::Project => {
214 db::projects::get_project_by_id(db, db::ProjectId::from(target_id))
215 .await?
216 .ok_or(AppError::NotFound)?;
217 }
218 FollowTargetType::Tag => {
219 db::tags::get_tag_by_id(db, db::TagId::from(target_id))
220 .await?
221 .ok_or(AppError::NotFound)?;
222 }
223 }
224
225 Ok(())
226 }
227