Skip to main content

max / makenotwork

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