Skip to main content

max / makenotwork

12.8 KB · 434 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 i.ai_tier
147 FROM items i
148 JOIN projects p ON i.project_id = p.id
149 JOIN users u ON p.user_id = u.id
150 LEFT JOIN item_tags pit ON pit.item_id = i.id AND pit.is_primary = true
151 LEFT JOIN tags pt ON pt.id = pit.tag_id
152 WHERE i.is_public = true AND p.is_public = true
153 AND (
154 p.user_id IN (
155 SELECT target_id FROM follows WHERE follower_id = $1 AND target_type = 'user'
156 )
157 OR p.id IN (
158 SELECT target_id FROM follows WHERE follower_id = $1 AND target_type = 'project'
159 )
160 OR i.id IN (
161 SELECT it.item_id FROM item_tags it
162 WHERE it.tag_id IN (
163 SELECT target_id FROM follows WHERE follower_id = $1 AND target_type = 'tag'
164 )
165 )
166 )
167 ORDER BY i.created_at DESC
168 LIMIT $2 OFFSET $3
169 "#,
170 )
171 .bind(follower_id)
172 .bind(limit)
173 .bind(offset)
174 .fetch_all(pool)
175 .await?;
176
177 Ok(items)
178 }
179
180 /// Count total feed items from all followed users, projects, and tags.
181 #[tracing::instrument(skip_all)]
182 pub async fn count_followed_feed_items(
183 pool: &PgPool,
184 follower_id: UserId,
185 ) -> Result<i64> {
186 let count: i64 = sqlx::query_scalar(
187 r#"
188 SELECT COUNT(DISTINCT i.id)
189 FROM items i
190 JOIN projects p ON i.project_id = p.id
191 WHERE i.is_public = true AND p.is_public = true
192 AND (
193 p.user_id IN (
194 SELECT target_id FROM follows WHERE follower_id = $1 AND target_type = 'user'
195 )
196 OR p.id IN (
197 SELECT target_id FROM follows WHERE follower_id = $1 AND target_type = 'project'
198 )
199 OR i.id IN (
200 SELECT it.item_id FROM item_tags it
201 WHERE it.tag_id IN (
202 SELECT target_id FROM follows WHERE follower_id = $1 AND target_type = 'tag'
203 )
204 )
205 )
206 "#,
207 )
208 .bind(follower_id)
209 .fetch_one(pool)
210 .await?;
211
212 Ok(count)
213 }
214
215 /// Get all tag IDs that a user follows, for batch lookup on the discover page.
216 #[tracing::instrument(skip_all)]
217 pub async fn get_followed_tag_ids(pool: &PgPool, follower_id: UserId) -> Result<HashSet<Uuid>> {
218 let rows: Vec<(Uuid,)> = sqlx::query_as(
219 "SELECT target_id FROM follows WHERE follower_id = $1 AND target_type = 'tag'",
220 )
221 .bind(follower_id)
222 .fetch_all(pool)
223 .await?;
224
225 Ok(rows.into_iter().map(|r| r.0).collect())
226 }
227
228 /// Export all followers of a user (direct user followers + project followers).
229 ///
230 /// Returns username, display_name, what they follow (user/project), and when.
231 #[tracing::instrument(skip_all)]
232 pub async fn get_followers_for_export(
233 pool: &PgPool,
234 user_id: UserId,
235 ) -> Result<Vec<FollowerExportRow>> {
236 let rows = sqlx::query_as::<_, FollowerExportRow>(
237 r#"
238 SELECT
239 u.username,
240 u.display_name,
241 f.target_type,
242 f.created_at,
243 CASE WHEN EXISTS (
244 SELECT 1 FROM transactions t
245 WHERE t.buyer_id = f.follower_id
246 AND t.seller_id = $1
247 AND t.status = 'completed'
248 AND t.share_contact = true
249 AND NOT EXISTS (
250 SELECT 1 FROM contact_revocations cr
251 WHERE cr.buyer_id = f.follower_id AND cr.seller_id = $1
252 )
253 ) THEN u.email ELSE NULL END AS email
254 FROM follows f
255 JOIN users u ON u.id = f.follower_id
256 WHERE (f.target_type = 'user' AND f.target_id = $1)
257 OR (f.target_type = 'project' AND f.target_id IN (SELECT id FROM projects WHERE user_id = $1))
258 ORDER BY f.created_at DESC
259 "#,
260 )
261 .bind(user_id)
262 .fetch_all(pool)
263 .await?;
264
265 Ok(rows)
266 }
267
268 /// Email address, display name, and user ID of a follower (for broadcast/notification).
269 #[derive(Debug, Clone, sqlx::FromRow)]
270 pub struct FollowerEmailRow {
271 pub id: UserId,
272 pub email: String,
273 pub display_name: Option<String>,
274 }
275
276 /// Get deduplicated email addresses of all followers (user + project follows).
277 /// Only includes verified, non-suspended users. Excludes suppressed addresses.
278 /// Capped at 10,000.
279 #[tracing::instrument(skip_all)]
280 pub async fn get_follower_emails(
281 pool: &PgPool,
282 creator_id: UserId,
283 ) -> Result<Vec<FollowerEmailRow>> {
284 let rows = sqlx::query_as::<_, FollowerEmailRow>(
285 r#"
286 SELECT DISTINCT u.id, u.email, u.display_name
287 FROM follows f
288 JOIN users u ON u.id = f.follower_id
289 WHERE u.email_verified = true
290 AND u.suspended_at IS NULL
291 AND NOT EXISTS (SELECT 1 FROM email_suppressions es WHERE LOWER(es.email) = LOWER(u.email))
292 AND (
293 (f.target_type = 'user' AND f.target_id = $1)
294 OR (f.target_type = 'project' AND f.target_id IN (SELECT id FROM projects WHERE user_id = $1))
295 )
296 LIMIT 10000
297 "#,
298 )
299 .bind(creator_id)
300 .fetch_all(pool)
301 .await?;
302
303 Ok(rows)
304 }
305
306
307 /// Get the follower count for a target.
308 #[tracing::instrument(skip_all)]
309 pub async fn get_follower_count(
310 pool: &PgPool,
311 target_type: FollowTargetType,
312 target_id: Uuid,
313 ) -> Result<i64> {
314 let row: (i64,) = sqlx::query_as(
315 "SELECT COUNT(*) FROM follows WHERE target_type = $1 AND target_id = $2",
316 )
317 .bind(target_type)
318 .bind(target_id)
319 .fetch_one(pool)
320 .await?;
321
322 Ok(row.0)
323 }
324
325 /// Count unique followers who would receive a broadcast email from this creator
326 /// (followers of the user + followers of their projects, deduplicated).
327 #[tracing::instrument(skip_all)]
328 pub async fn get_broadcast_follower_count(
329 pool: &PgPool,
330 creator_id: UserId,
331 ) -> Result<i64> {
332 let row: (i64,) = sqlx::query_as(
333 r#"
334 SELECT COUNT(DISTINCT f.follower_id)
335 FROM follows f
336 JOIN users u ON u.id = f.follower_id
337 WHERE u.email_verified = true
338 AND u.suspended_at IS NULL
339 AND NOT EXISTS (SELECT 1 FROM email_suppressions es WHERE LOWER(es.email) = LOWER(u.email))
340 AND (
341 (f.target_type = 'user' AND f.target_id = $1)
342 OR (f.target_type = 'project' AND f.target_id IN (SELECT id FROM projects WHERE user_id = $1))
343 )
344 "#,
345 )
346 .bind(creator_id)
347 .fetch_one(pool)
348 .await?;
349
350 Ok(row.0)
351 }
352
353 #[cfg(test)]
354 mod tests {
355 use super::*;
356
357 #[test]
358 fn follow_target_type_display() {
359 assert_eq!(FollowTargetType::User.to_string(), "user");
360 assert_eq!(FollowTargetType::Project.to_string(), "project");
361 assert_eq!(FollowTargetType::Tag.to_string(), "tag");
362 }
363
364 #[test]
365 fn follow_target_type_parse() {
366 assert_eq!("user".parse::<FollowTargetType>().unwrap(), FollowTargetType::User);
367 assert_eq!("project".parse::<FollowTargetType>().unwrap(), FollowTargetType::Project);
368 assert_eq!("tag".parse::<FollowTargetType>().unwrap(), FollowTargetType::Tag);
369 }
370
371 #[test]
372 fn follow_target_type_parse_invalid() {
373 assert!("invalid".parse::<FollowTargetType>().is_err());
374 assert!("User".parse::<FollowTargetType>().is_err());
375 assert!("".parse::<FollowTargetType>().is_err());
376 }
377
378 #[test]
379 fn follow_target_type_round_trip() {
380 for variant in [FollowTargetType::User, FollowTargetType::Project, FollowTargetType::Tag] {
381 let s = variant.to_string();
382 let parsed: FollowTargetType = s.parse().unwrap();
383 assert_eq!(variant, parsed);
384 }
385 }
386
387 #[test]
388 fn follower_email_row_constructible() {
389 let row = FollowerEmailRow {
390 id: UserId::new(),
391 email: "test@example.com".to_string(),
392 display_name: Some("Test User".to_string()),
393 };
394 assert_eq!(row.email, "test@example.com");
395 assert_eq!(row.display_name.as_deref(), Some("Test User"));
396 }
397
398 #[test]
399 fn follower_email_row_no_display_name() {
400 let row = FollowerEmailRow {
401 id: UserId::nil(),
402 email: "a@b.com".to_string(),
403 display_name: None,
404 };
405 assert!(row.display_name.is_none());
406 }
407
408 #[test]
409 fn followed_tag_ids_collection() {
410 // Mirrors the collect logic in get_followed_tag_ids
411 let uuids: Vec<(Uuid,)> = vec![
412 (Uuid::new_v4(),),
413 (Uuid::new_v4(),),
414 ];
415 let set: HashSet<Uuid> = uuids.iter().map(|r| r.0).collect();
416 assert_eq!(set.len(), 2);
417 }
418
419 #[test]
420 fn followed_tag_ids_dedup() {
421 let id = Uuid::new_v4();
422 let uuids: Vec<(Uuid,)> = vec![(id,), (id,)];
423 let set: HashSet<Uuid> = uuids.into_iter().map(|r| r.0).collect();
424 assert_eq!(set.len(), 1);
425 }
426
427 #[test]
428 fn user_id_equality() {
429 let id = UserId::new();
430 let same = UserId::from_uuid(*id.as_uuid());
431 assert_eq!(id, same);
432 }
433 }
434