Skip to main content

max / makenotwork

15.0 KB · 458 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::UserId;
9 use super::enums::FollowTargetType;
10 use super::models::FollowerExportRow;
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(crate) 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(crate) 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(crate) 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 /// The set of item ids visible in a follower's feed: everything from a followed
78 /// user, a followed project, or a followed tag. `$1` is the follower.
79 ///
80 /// Tag following is HIERARCHICAL, following a branch follows everything under
81 /// it, matching how the discover tag filter has always behaved
82 /// (`db::discover::TAG_CLAUSE`). The two used to disagree while sitting next to
83 /// each other in the drill-down: the filter expanded via `path LIKE`, following
84 /// joined `follows.target_id = item_tags.tag_id` exactly. Since `item_tags`
85 /// holds only directly-assigned tags and the write path enforces `depth >= 3`,
86 /// following any depth-1 or depth-2 tag matched nothing, forever.
87 ///
88 /// The descendant match is `t.path LIKE ft.path || '.%'` rather than a
89 /// `tagtree::like_descendant_pattern` bind, because the followed paths are not
90 /// known host-side here, they come out of `follows` in the same query. That is
91 /// safe only because `tagtree` validation restricts a path to lowercase
92 /// alphanumerics, hyphens, and dots, so `escape_like` is a no-op on every value
93 /// that can reach this column. If that charset ever widens, this must become a
94 /// bound pattern built by `tagtree::like_descendant_pattern`.
95 ///
96 /// Shared verbatim by the three feed queries below so the three cannot drift.
97 const FOLLOWED_ITEM_IDS_CTE: &str = r"
98 WITH followed_item_ids AS (
99 SELECT i.id FROM items i
100 JOIN projects p ON i.project_id = p.id
101 JOIN users u ON u.id = p.user_id
102 JOIN follows f ON f.follower_id = $1 AND f.target_type = 'user' AND f.target_id = p.user_id
103 WHERE i.is_public = true AND i.listed = true AND p.is_public = true AND i.scan_status = 'clean' AND u.is_sandbox = FALSE AND i.deleted_at IS NULL
104 UNION
105 SELECT i.id FROM items i
106 JOIN projects p ON i.project_id = p.id
107 JOIN users u ON u.id = p.user_id
108 JOIN follows f ON f.follower_id = $1 AND f.target_type = 'project' AND f.target_id = p.id
109 WHERE i.is_public = true AND i.listed = true AND p.is_public = true AND i.scan_status = 'clean' AND u.is_sandbox = FALSE AND i.deleted_at IS NULL
110 UNION
111 SELECT i.id FROM items i
112 JOIN projects p ON i.project_id = p.id
113 JOIN users u ON u.id = p.user_id
114 JOIN item_tags it ON it.item_id = i.id
115 JOIN tags t ON t.id = it.tag_id
116 JOIN follows f ON f.follower_id = $1 AND f.target_type = 'tag'
117 JOIN tags ft ON ft.id = f.target_id
118 WHERE (t.id = ft.id OR t.path LIKE ft.path || '.%')
119 AND i.is_public = true AND i.listed = true AND p.is_public = true AND i.scan_status = 'clean' AND u.is_sandbox = FALSE AND i.deleted_at IS NULL
120 )
121 ";
122
123 /// Get recent public items from all users and projects this user follows.
124 /// Returns up to 50 items, newest first.
125 #[tracing::instrument(skip_all)]
126 pub(crate) async fn get_followed_items(
127 pool: &PgPool,
128 follower_id: UserId,
129 ) -> Result<Vec<super::models::DbItem>> {
130 let items = sqlx::query_as::<_, super::models::DbItem>(&format!(
131 r"{FOLLOWED_ITEM_IDS_CTE}
132 SELECT i.* FROM items i
133 JOIN followed_item_ids fi ON fi.id = i.id
134 ORDER BY i.created_at DESC
135 LIMIT 50
136 "
137 ))
138 .bind(follower_id)
139 .fetch_all(pool)
140 .await?;
141
142 Ok(items)
143 }
144
145 /// Get paginated feed items from all users, projects, and tags this user follows.
146 /// Returns discover-style rows for consistent template rendering.
147 #[tracing::instrument(skip_all)]
148 pub(crate) async fn get_followed_feed_items(
149 pool: &PgPool,
150 follower_id: UserId,
151 limit: i64,
152 offset: i64,
153 ) -> Result<Vec<super::models::DbDiscoverItemRow>> {
154 let items = sqlx::query_as::<_, super::models::DbDiscoverItemRow>(&format!(
155 r"{FOLLOWED_ITEM_IDS_CTE}
156 SELECT
157 i.id,
158 i.title,
159 i.description,
160 i.price_cents,
161 i.item_type,
162 i.created_at,
163 u.username,
164 u.settlement_currency,
165 p.title as project_title,
166 i.sales_count::bigint,
167 pt.name as primary_tag_name,
168 i.pwyw_enabled,
169 i.pwyw_min_cents,
170 NULL::smallint as match_tier,
171 NULL::int as matched_terms,
172 NULL::int as query_terms,
173 NULL::real as match_score,
174 i.ai_tier
175 FROM items i
176 JOIN followed_item_ids fi ON fi.id = i.id
177 JOIN projects p ON i.project_id = p.id
178 JOIN users u ON p.user_id = u.id
179 LEFT JOIN item_tags pit ON pit.item_id = i.id AND pit.is_primary = true
180 LEFT JOIN tags pt ON pt.id = pit.tag_id
181 ORDER BY i.created_at DESC
182 LIMIT $2 OFFSET $3
183 "
184 ))
185 .bind(follower_id)
186 .bind(limit)
187 .bind(offset)
188 .fetch_all(pool)
189 .await?;
190
191 Ok(items)
192 }
193
194 /// Count total feed items from all followed users, projects, and tags.
195 #[tracing::instrument(skip_all)]
196 pub(crate) async fn count_followed_feed_items(pool: &PgPool, follower_id: UserId) -> Result<i64> {
197 let count: i64 = sqlx::query_scalar(&format!(
198 r"{FOLLOWED_ITEM_IDS_CTE}
199 SELECT COUNT(*) FROM followed_item_ids
200 "
201 ))
202 .bind(follower_id)
203 .fetch_one(pool)
204 .await?;
205
206 Ok(count)
207 }
208
209 /// Get all tag IDs that a user follows, for batch lookup on the discover page.
210 #[tracing::instrument(skip_all)]
211 /// Which of `tag_ids` the follower follows. Scoped variant of
212 /// `get_followed_tag_ids`: the discover facet sidebar only renders ~10 tags and
213 /// tests membership for those, so fetching the viewer's entire followed-tag set
214 /// per render was an over-fetch (fuzz 2026-07-06 C5-1). Single `= ANY($2)`
215 /// roundtrip. Returns an empty set for an empty input.
216 pub(crate) async fn following_subset(
217 pool: &PgPool,
218 follower_id: UserId,
219 tag_ids: &[crate::db::TagId],
220 ) -> Result<HashSet<crate::db::TagId>> {
221 if tag_ids.is_empty() {
222 return Ok(HashSet::new());
223 }
224 let rows: Vec<(crate::db::TagId,)> = sqlx::query_as(
225 "SELECT target_id FROM follows \
226 WHERE follower_id = $1 AND target_type = 'tag' AND target_id = ANY($2)",
227 )
228 .bind(follower_id)
229 .bind(tag_ids)
230 .fetch_all(pool)
231 .await?;
232
233 Ok(rows.into_iter().map(|r| r.0).collect())
234 }
235
236 /// Export all followers of a user (direct user followers + project followers).
237 ///
238 /// Returns username, display_name, what they follow (user/project), and when.
239 #[tracing::instrument(skip_all)]
240 /// One page of a creator's followers for CSV export, newest first.
241 ///
242 /// Paginated (`LIMIT`/`OFFSET`) so the export streams in bounded batches rather
243 /// than materializing every follower in one query, and so the per-row email
244 /// `EXISTS` reveal runs only for a bounded page at a time (ultra-fuzz Run 4 S1).
245 /// Stable `(created_at, follower_id)` ordering keeps OFFSET batches consistent.
246 pub(crate) async fn get_followers_for_export_page(
247 pool: &PgPool,
248 user_id: UserId,
249 limit: i64,
250 offset: i64,
251 ) -> Result<Vec<FollowerExportRow>> {
252 let rows = sqlx::query_as::<_, FollowerExportRow>(
253 r"
254 SELECT
255 u.username,
256 u.display_name,
257 f.target_type,
258 f.created_at,
259 CASE WHEN EXISTS (
260 SELECT 1 FROM transactions t
261 WHERE t.buyer_id = f.follower_id
262 AND t.seller_id = $1
263 AND t.status = 'completed'
264 AND t.share_contact = true
265 AND NOT EXISTS (
266 SELECT 1 FROM contact_revocations cr
267 WHERE cr.buyer_id = f.follower_id AND cr.seller_id = $1
268 )
269 ) THEN u.email ELSE NULL END AS email
270 FROM follows f
271 JOIN users u ON u.id = f.follower_id
272 WHERE (f.target_type = 'user' AND f.target_id = $1)
273 OR (f.target_type = 'project' AND f.target_id IN (SELECT id FROM projects WHERE user_id = $1))
274 ORDER BY f.created_at DESC, f.follower_id DESC
275 LIMIT $2 OFFSET $3
276 ",
277 )
278 .bind(user_id)
279 .bind(limit)
280 .bind(offset)
281 .fetch_all(pool)
282 .await?;
283
284 Ok(rows)
285 }
286
287 /// Email address, display name, and user ID of a follower (for broadcast/notification).
288 #[derive(Debug, Clone, sqlx::FromRow)]
289 pub(crate) struct FollowerEmailRow {
290 pub id: UserId,
291 pub email: String,
292 pub display_name: Option<String>,
293 }
294
295 /// Get deduplicated email addresses of all followers (user + project follows).
296 /// Only includes verified, non-suspended users. Excludes suppressed addresses.
297 /// Capped at 10,000.
298 #[tracing::instrument(skip_all)]
299 pub(crate) async fn get_follower_emails(
300 pool: &PgPool,
301 creator_id: UserId,
302 ) -> Result<Vec<FollowerEmailRow>> {
303 let rows = sqlx::query_as::<_, FollowerEmailRow>(
304 r"
305 SELECT DISTINCT u.id, u.email, u.display_name
306 FROM follows f
307 JOIN users u ON u.id = f.follower_id
308 WHERE u.email_verified = true
309 AND u.suspended_at IS NULL
310 AND NOT EXISTS (SELECT 1 FROM email_suppressions es WHERE LOWER(es.email) = LOWER(u.email))
311 AND (
312 (f.target_type = 'user' AND f.target_id = $1)
313 OR (f.target_type = 'project' AND f.target_id IN (SELECT id FROM projects WHERE user_id = $1))
314 )
315 LIMIT 10000
316 ",
317 )
318 .bind(creator_id)
319 .fetch_all(pool)
320 .await?;
321
322 Ok(rows)
323 }
324
325 /// Get the follower count for a target.
326 #[tracing::instrument(skip_all)]
327 pub(crate) async fn get_follower_count(
328 pool: &PgPool,
329 target_type: FollowTargetType,
330 target_id: Uuid,
331 ) -> Result<i64> {
332 let row: (i64,) =
333 sqlx::query_as("SELECT COUNT(*) FROM follows WHERE target_type = $1 AND target_id = $2")
334 .bind(target_type)
335 .bind(target_id)
336 .fetch_one(pool)
337 .await?;
338
339 Ok(row.0)
340 }
341
342 /// Count unique followers who would receive a broadcast email from this creator
343 /// (followers of the user + followers of their projects, deduplicated).
344 #[tracing::instrument(skip_all)]
345 pub(crate) async fn get_broadcast_follower_count(pool: &PgPool, creator_id: UserId) -> Result<i64> {
346 let row: (i64,) = sqlx::query_as(
347 r"
348 SELECT COUNT(DISTINCT f.follower_id)
349 FROM follows f
350 JOIN users u ON u.id = f.follower_id
351 WHERE u.email_verified = true
352 AND u.suspended_at IS NULL
353 AND NOT EXISTS (SELECT 1 FROM email_suppressions es WHERE LOWER(es.email) = LOWER(u.email))
354 AND (
355 (f.target_type = 'user' AND f.target_id = $1)
356 OR (f.target_type = 'project' AND f.target_id IN (SELECT id FROM projects WHERE user_id = $1))
357 )
358 ",
359 )
360 .bind(creator_id)
361 .fetch_one(pool)
362 .await?;
363
364 Ok(row.0)
365 }
366
367 #[cfg(test)]
368 mod tests {
369 use super::*;
370
371 #[test]
372 fn follow_target_type_display() {
373 assert_eq!(FollowTargetType::User.to_string(), "user");
374 assert_eq!(FollowTargetType::Project.to_string(), "project");
375 assert_eq!(FollowTargetType::Tag.to_string(), "tag");
376 }
377
378 #[test]
379 fn follow_target_type_parse() {
380 assert_eq!(
381 "user".parse::<FollowTargetType>().unwrap(),
382 FollowTargetType::User
383 );
384 assert_eq!(
385 "project".parse::<FollowTargetType>().unwrap(),
386 FollowTargetType::Project
387 );
388 assert_eq!(
389 "tag".parse::<FollowTargetType>().unwrap(),
390 FollowTargetType::Tag
391 );
392 }
393
394 #[test]
395 fn follow_target_type_parse_invalid() {
396 assert!("invalid".parse::<FollowTargetType>().is_err());
397 assert!("User".parse::<FollowTargetType>().is_err());
398 assert!("".parse::<FollowTargetType>().is_err());
399 }
400
401 #[test]
402 fn follow_target_type_round_trip() {
403 for variant in [
404 FollowTargetType::User,
405 FollowTargetType::Project,
406 FollowTargetType::Tag,
407 ] {
408 let s = variant.to_string();
409 let parsed: FollowTargetType = s.parse().unwrap();
410 assert_eq!(variant, parsed);
411 }
412 }
413
414 #[test]
415 fn follower_email_row_constructible() {
416 let row = FollowerEmailRow {
417 id: UserId::new(),
418 email: "test@example.com".to_string(),
419 display_name: Some("Test User".to_string()),
420 };
421 assert_eq!(row.email, "test@example.com");
422 assert_eq!(row.display_name.as_deref(), Some("Test User"));
423 }
424
425 #[test]
426 fn follower_email_row_no_display_name() {
427 let row = FollowerEmailRow {
428 id: UserId::nil(),
429 email: "a@b.com".to_string(),
430 display_name: None,
431 };
432 assert!(row.display_name.is_none());
433 }
434
435 #[test]
436 fn followed_tag_ids_collection() {
437 // Mirrors the collect logic in get_followed_tag_ids
438 let uuids: Vec<(Uuid,)> = vec![(Uuid::new_v4(),), (Uuid::new_v4(),)];
439 let set: HashSet<Uuid> = uuids.iter().map(|r| r.0).collect();
440 assert_eq!(set.len(), 2);
441 }
442
443 #[test]
444 fn followed_tag_ids_dedup() {
445 let id = Uuid::new_v4();
446 let uuids: Vec<(Uuid,)> = vec![(id,), (id,)];
447 let set: HashSet<Uuid> = uuids.into_iter().map(|r| r.0).collect();
448 assert_eq!(set.len(), 1);
449 }
450
451 #[test]
452 fn user_id_equality() {
453 let id = UserId::new();
454 let same = UserId::from_uuid(*id.as_uuid());
455 assert_eq!(id, same);
456 }
457 }
458