Skip to main content

max / makenotwork

12.7 KB · 433 lines History Blame Raw
1 //! Follow system: users can follow other users and projects.
2
3 use std::collections::HashSet;
4
5 use sqlx::PgPool;
6 use uuid::Uuid;
7
8 use super::enums::FollowTargetType;
9 use super::models::FollowerExportRow;
10 use super::UserId;
11 use crate::error::Result;
12
13 /// Follow a target (user or project). Idempotent; does nothing if already following.
14 #[tracing::instrument(skip_all)]
15 pub async fn follow(
16 pool: &PgPool,
17 follower_id: UserId,
18 target_type: FollowTargetType,
19 target_id: Uuid,
20 ) -> Result<()> {
21 sqlx::query(
22 r#"
23 INSERT INTO follows (follower_id, target_type, target_id)
24 VALUES ($1, $2, $3)
25 ON CONFLICT (follower_id, target_type, target_id) DO NOTHING
26 "#,
27 )
28 .bind(follower_id)
29 .bind(target_type)
30 .bind(target_id)
31 .execute(pool)
32 .await?;
33
34 Ok(())
35 }
36
37 /// Unfollow a target. Returns true if a row was deleted.
38 #[tracing::instrument(skip_all)]
39 pub async fn unfollow(
40 pool: &PgPool,
41 follower_id: UserId,
42 target_type: FollowTargetType,
43 target_id: Uuid,
44 ) -> Result<bool> {
45 let result = sqlx::query(
46 "DELETE FROM follows WHERE follower_id = $1 AND target_type = $2 AND target_id = $3",
47 )
48 .bind(follower_id)
49 .bind(target_type)
50 .bind(target_id)
51 .execute(pool)
52 .await?;
53
54 Ok(result.rows_affected() > 0)
55 }
56
57 /// Check if a user is following a target.
58 #[tracing::instrument(skip_all)]
59 pub async fn is_following(
60 pool: &PgPool,
61 follower_id: UserId,
62 target_type: FollowTargetType,
63 target_id: Uuid,
64 ) -> Result<bool> {
65 let row: (bool,) = sqlx::query_as(
66 "SELECT EXISTS(SELECT 1 FROM follows WHERE follower_id = $1 AND target_type = $2 AND target_id = $3)",
67 )
68 .bind(follower_id)
69 .bind(target_type)
70 .bind(target_id)
71 .fetch_one(pool)
72 .await?;
73
74 Ok(row.0)
75 }
76
77 /// Get recent public items from all users and projects this user follows.
78 /// Returns up to 50 items, newest first.
79 #[tracing::instrument(skip_all)]
80 pub async fn get_followed_items(
81 pool: &PgPool,
82 follower_id: UserId,
83 ) -> Result<Vec<super::models::DbItem>> {
84 let items = sqlx::query_as::<_, super::models::DbItem>(
85 r#"
86 SELECT DISTINCT i.* FROM items i
87 JOIN projects p ON i.project_id = p.id
88 WHERE i.is_public = true AND p.is_public = true
89 AND (
90 -- Items from followed users
91 p.user_id IN (
92 SELECT target_id FROM follows
93 WHERE follower_id = $1 AND target_type = 'user'
94 )
95 OR
96 -- Items from followed projects
97 p.id IN (
98 SELECT target_id FROM follows
99 WHERE follower_id = $1 AND target_type = 'project'
100 )
101 OR
102 -- Items with followed tags
103 i.id IN (
104 SELECT it.item_id FROM item_tags it
105 WHERE it.tag_id IN (
106 SELECT target_id FROM follows WHERE follower_id = $1 AND target_type = 'tag'
107 )
108 )
109 )
110 ORDER BY i.created_at DESC
111 LIMIT 50
112 "#,
113 )
114 .bind(follower_id)
115 .fetch_all(pool)
116 .await?;
117
118 Ok(items)
119 }
120
121 /// Get paginated feed items from all users, projects, and tags this user follows.
122 /// Returns discover-style rows for consistent template rendering.
123 #[tracing::instrument(skip_all)]
124 pub async fn get_followed_feed_items(
125 pool: &PgPool,
126 follower_id: UserId,
127 limit: i64,
128 offset: i64,
129 ) -> Result<Vec<super::models::DbDiscoverItemRow>> {
130 let items = sqlx::query_as::<_, super::models::DbDiscoverItemRow>(
131 r#"
132 SELECT DISTINCT
133 i.id,
134 i.title,
135 i.description,
136 i.price_cents,
137 i.item_type,
138 i.created_at,
139 u.username,
140 p.title as project_title,
141 i.sales_count::bigint,
142 pt.name as primary_tag_name,
143 i.pwyw_enabled,
144 i.pwyw_min_cents,
145 NULL::real as match_score
146 FROM items i
147 JOIN projects p ON i.project_id = p.id
148 JOIN users u ON p.user_id = u.id
149 LEFT JOIN item_tags pit ON pit.item_id = i.id AND pit.is_primary = true
150 LEFT JOIN tags pt ON pt.id = pit.tag_id
151 WHERE i.is_public = true AND p.is_public = true
152 AND (
153 p.user_id IN (
154 SELECT target_id FROM follows WHERE follower_id = $1 AND target_type = 'user'
155 )
156 OR p.id IN (
157 SELECT target_id FROM follows WHERE follower_id = $1 AND target_type = 'project'
158 )
159 OR i.id IN (
160 SELECT it.item_id FROM item_tags it
161 WHERE it.tag_id IN (
162 SELECT target_id FROM follows WHERE follower_id = $1 AND target_type = 'tag'
163 )
164 )
165 )
166 ORDER BY i.created_at DESC
167 LIMIT $2 OFFSET $3
168 "#,
169 )
170 .bind(follower_id)
171 .bind(limit)
172 .bind(offset)
173 .fetch_all(pool)
174 .await?;
175
176 Ok(items)
177 }
178
179 /// Count total feed items from all followed users, projects, and tags.
180 #[tracing::instrument(skip_all)]
181 pub async fn count_followed_feed_items(
182 pool: &PgPool,
183 follower_id: UserId,
184 ) -> Result<i64> {
185 let count: i64 = sqlx::query_scalar(
186 r#"
187 SELECT COUNT(DISTINCT i.id)
188 FROM items i
189 JOIN projects p ON i.project_id = p.id
190 WHERE i.is_public = true AND p.is_public = true
191 AND (
192 p.user_id IN (
193 SELECT target_id FROM follows WHERE follower_id = $1 AND target_type = 'user'
194 )
195 OR p.id IN (
196 SELECT target_id FROM follows WHERE follower_id = $1 AND target_type = 'project'
197 )
198 OR i.id IN (
199 SELECT it.item_id FROM item_tags it
200 WHERE it.tag_id IN (
201 SELECT target_id FROM follows WHERE follower_id = $1 AND target_type = 'tag'
202 )
203 )
204 )
205 "#,
206 )
207 .bind(follower_id)
208 .fetch_one(pool)
209 .await?;
210
211 Ok(count)
212 }
213
214 /// Get all tag IDs that a user follows, for batch lookup on the discover page.
215 #[tracing::instrument(skip_all)]
216 pub async fn get_followed_tag_ids(pool: &PgPool, follower_id: UserId) -> Result<HashSet<Uuid>> {
217 let rows: Vec<(Uuid,)> = sqlx::query_as(
218 "SELECT target_id FROM follows WHERE follower_id = $1 AND target_type = 'tag'",
219 )
220 .bind(follower_id)
221 .fetch_all(pool)
222 .await?;
223
224 Ok(rows.into_iter().map(|r| r.0).collect())
225 }
226
227 /// Export all followers of a user (direct user followers + project followers).
228 ///
229 /// Returns username, display_name, what they follow (user/project), and when.
230 #[tracing::instrument(skip_all)]
231 pub async fn get_followers_for_export(
232 pool: &PgPool,
233 user_id: UserId,
234 ) -> Result<Vec<FollowerExportRow>> {
235 let rows = sqlx::query_as::<_, FollowerExportRow>(
236 r#"
237 SELECT
238 u.username,
239 u.display_name,
240 f.target_type,
241 f.created_at,
242 CASE WHEN EXISTS (
243 SELECT 1 FROM transactions t
244 WHERE t.buyer_id = f.follower_id
245 AND t.seller_id = $1
246 AND t.status = 'completed'
247 AND t.share_contact = true
248 AND NOT EXISTS (
249 SELECT 1 FROM contact_revocations cr
250 WHERE cr.buyer_id = f.follower_id AND cr.seller_id = $1
251 )
252 ) THEN u.email ELSE NULL END AS email
253 FROM follows f
254 JOIN users u ON u.id = f.follower_id
255 WHERE (f.target_type = 'user' AND f.target_id = $1)
256 OR (f.target_type = 'project' AND f.target_id IN (SELECT id FROM projects WHERE user_id = $1))
257 ORDER BY f.created_at DESC
258 "#,
259 )
260 .bind(user_id)
261 .fetch_all(pool)
262 .await?;
263
264 Ok(rows)
265 }
266
267 /// Email address, display name, and user ID of a follower (for broadcast/notification).
268 #[derive(Debug, Clone, sqlx::FromRow)]
269 pub struct FollowerEmailRow {
270 pub id: UserId,
271 pub email: String,
272 pub display_name: Option<String>,
273 }
274
275 /// Get deduplicated email addresses of all followers (user + project follows).
276 /// Only includes verified, non-suspended users. Excludes suppressed addresses.
277 /// Capped at 10,000.
278 #[tracing::instrument(skip_all)]
279 pub async fn get_follower_emails(
280 pool: &PgPool,
281 creator_id: UserId,
282 ) -> Result<Vec<FollowerEmailRow>> {
283 let rows = sqlx::query_as::<_, FollowerEmailRow>(
284 r#"
285 SELECT DISTINCT u.id, u.email, u.display_name
286 FROM follows f
287 JOIN users u ON u.id = f.follower_id
288 WHERE u.email_verified = true
289 AND u.suspended_at IS NULL
290 AND NOT EXISTS (SELECT 1 FROM email_suppressions es WHERE LOWER(es.email) = LOWER(u.email))
291 AND (
292 (f.target_type = 'user' AND f.target_id = $1)
293 OR (f.target_type = 'project' AND f.target_id IN (SELECT id FROM projects WHERE user_id = $1))
294 )
295 LIMIT 10000
296 "#,
297 )
298 .bind(creator_id)
299 .fetch_all(pool)
300 .await?;
301
302 Ok(rows)
303 }
304
305
306 /// Get the follower count for a target.
307 #[tracing::instrument(skip_all)]
308 pub async fn get_follower_count(
309 pool: &PgPool,
310 target_type: FollowTargetType,
311 target_id: Uuid,
312 ) -> Result<i64> {
313 let row: (i64,) = sqlx::query_as(
314 "SELECT COUNT(*) FROM follows WHERE target_type = $1 AND target_id = $2",
315 )
316 .bind(target_type)
317 .bind(target_id)
318 .fetch_one(pool)
319 .await?;
320
321 Ok(row.0)
322 }
323
324 /// Count unique followers who would receive a broadcast email from this creator
325 /// (followers of the user + followers of their projects, deduplicated).
326 #[tracing::instrument(skip_all)]
327 pub async fn get_broadcast_follower_count(
328 pool: &PgPool,
329 creator_id: UserId,
330 ) -> Result<i64> {
331 let row: (i64,) = sqlx::query_as(
332 r#"
333 SELECT COUNT(DISTINCT f.follower_id)
334 FROM follows f
335 JOIN users u ON u.id = f.follower_id
336 WHERE u.email_verified = true
337 AND u.suspended_at IS NULL
338 AND NOT EXISTS (SELECT 1 FROM email_suppressions es WHERE LOWER(es.email) = LOWER(u.email))
339 AND (
340 (f.target_type = 'user' AND f.target_id = $1)
341 OR (f.target_type = 'project' AND f.target_id IN (SELECT id FROM projects WHERE user_id = $1))
342 )
343 "#,
344 )
345 .bind(creator_id)
346 .fetch_one(pool)
347 .await?;
348
349 Ok(row.0)
350 }
351
352 #[cfg(test)]
353 mod tests {
354 use super::*;
355
356 #[test]
357 fn follow_target_type_display() {
358 assert_eq!(FollowTargetType::User.to_string(), "user");
359 assert_eq!(FollowTargetType::Project.to_string(), "project");
360 assert_eq!(FollowTargetType::Tag.to_string(), "tag");
361 }
362
363 #[test]
364 fn follow_target_type_parse() {
365 assert_eq!("user".parse::<FollowTargetType>().unwrap(), FollowTargetType::User);
366 assert_eq!("project".parse::<FollowTargetType>().unwrap(), FollowTargetType::Project);
367 assert_eq!("tag".parse::<FollowTargetType>().unwrap(), FollowTargetType::Tag);
368 }
369
370 #[test]
371 fn follow_target_type_parse_invalid() {
372 assert!("invalid".parse::<FollowTargetType>().is_err());
373 assert!("User".parse::<FollowTargetType>().is_err());
374 assert!("".parse::<FollowTargetType>().is_err());
375 }
376
377 #[test]
378 fn follow_target_type_round_trip() {
379 for variant in [FollowTargetType::User, FollowTargetType::Project, FollowTargetType::Tag] {
380 let s = variant.to_string();
381 let parsed: FollowTargetType = s.parse().unwrap();
382 assert_eq!(variant, parsed);
383 }
384 }
385
386 #[test]
387 fn follower_email_row_constructible() {
388 let row = FollowerEmailRow {
389 id: UserId::new(),
390 email: "test@example.com".to_string(),
391 display_name: Some("Test User".to_string()),
392 };
393 assert_eq!(row.email, "test@example.com");
394 assert_eq!(row.display_name.as_deref(), Some("Test User"));
395 }
396
397 #[test]
398 fn follower_email_row_no_display_name() {
399 let row = FollowerEmailRow {
400 id: UserId::nil(),
401 email: "a@b.com".to_string(),
402 display_name: None,
403 };
404 assert!(row.display_name.is_none());
405 }
406
407 #[test]
408 fn followed_tag_ids_collection() {
409 // Mirrors the collect logic in get_followed_tag_ids
410 let uuids: Vec<(Uuid,)> = vec![
411 (Uuid::new_v4(),),
412 (Uuid::new_v4(),),
413 ];
414 let set: HashSet<Uuid> = uuids.iter().map(|r| r.0).collect();
415 assert_eq!(set.len(), 2);
416 }
417
418 #[test]
419 fn followed_tag_ids_dedup() {
420 let id = Uuid::new_v4();
421 let uuids: Vec<(Uuid,)> = vec![(id,), (id,)];
422 let set: HashSet<Uuid> = uuids.into_iter().map(|r| r.0).collect();
423 assert_eq!(set.len(), 1);
424 }
425
426 #[test]
427 fn user_id_equality() {
428 let id = UserId::new();
429 let same = UserId::from_uuid(*id.as_uuid());
430 assert_eq!(id, same);
431 }
432 }
433