Skip to main content

max / makenotwork

15.7 KB · 479 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. 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.
245 /// Stable `(created_at, follower_id)` ordering keeps OFFSET batches consistent.
246 /// How many rows the follower export will page through.
247 ///
248 /// The same set `get_followers_for_export_page` walks, counted rather than
249 /// paged. Asked once before the export starts, so `follower_exports` can record
250 /// what was about to be handed over (`159a7a20`).
251 #[tracing::instrument(skip_all)]
252 pub(crate) async fn count_followers_for_export(pool: &PgPool, user_id: UserId) -> Result<i64> {
253 let count = sqlx::query_scalar::<_, i64>(
254 r"
255 SELECT COUNT(*)
256 FROM follows f
257 WHERE (f.target_type = 'user' AND f.target_id = $1)
258 OR (f.target_type = 'project' AND f.target_id IN (SELECT id FROM projects WHERE user_id = $1))
259 ",
260 )
261 .bind(user_id)
262 .fetch_one(pool)
263 .await?;
264 Ok(count)
265 }
266
267 pub(crate) async fn get_followers_for_export_page(
268 pool: &PgPool,
269 user_id: UserId,
270 limit: i64,
271 offset: i64,
272 ) -> Result<Vec<FollowerExportRow>> {
273 let rows = sqlx::query_as::<_, FollowerExportRow>(
274 r"
275 SELECT
276 u.username,
277 u.display_name,
278 f.target_type,
279 f.created_at,
280 CASE WHEN EXISTS (
281 SELECT 1 FROM transactions t
282 WHERE t.buyer_id = f.follower_id
283 AND t.seller_id = $1
284 AND t.status = 'completed'
285 AND t.share_contact = true
286 AND NOT EXISTS (
287 SELECT 1 FROM contact_revocations cr
288 WHERE cr.buyer_id = f.follower_id AND cr.seller_id = $1
289 )
290 ) THEN u.email ELSE NULL END AS email
291 FROM follows f
292 JOIN users u ON u.id = f.follower_id
293 WHERE (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 ORDER BY f.created_at DESC, f.follower_id DESC
296 LIMIT $2 OFFSET $3
297 ",
298 )
299 .bind(user_id)
300 .bind(limit)
301 .bind(offset)
302 .fetch_all(pool)
303 .await?;
304
305 Ok(rows)
306 }
307
308 /// Email address, display name, and user ID of a follower (for broadcast/notification).
309 #[derive(Debug, Clone, sqlx::FromRow)]
310 pub(crate) struct FollowerEmailRow {
311 pub id: UserId,
312 pub email: String,
313 pub display_name: Option<String>,
314 }
315
316 /// Get deduplicated email addresses of all followers (user + project follows).
317 /// Only includes verified, non-suspended users. Excludes suppressed addresses.
318 /// Capped at 10,000.
319 #[tracing::instrument(skip_all)]
320 pub(crate) async fn get_follower_emails(
321 pool: &PgPool,
322 creator_id: UserId,
323 ) -> Result<Vec<FollowerEmailRow>> {
324 let rows = sqlx::query_as::<_, FollowerEmailRow>(
325 r"
326 SELECT DISTINCT u.id, u.email, u.display_name
327 FROM follows f
328 JOIN users u ON u.id = f.follower_id
329 WHERE u.email_verified = true
330 AND u.suspended_at IS NULL
331 AND NOT EXISTS (SELECT 1 FROM email_suppressions es WHERE LOWER(es.email) = LOWER(u.email))
332 AND (
333 (f.target_type = 'user' AND f.target_id = $1)
334 OR (f.target_type = 'project' AND f.target_id IN (SELECT id FROM projects WHERE user_id = $1))
335 )
336 LIMIT 10000
337 ",
338 )
339 .bind(creator_id)
340 .fetch_all(pool)
341 .await?;
342
343 Ok(rows)
344 }
345
346 /// Get the follower count for a target.
347 #[tracing::instrument(skip_all)]
348 pub(crate) async fn get_follower_count(
349 pool: &PgPool,
350 target_type: FollowTargetType,
351 target_id: Uuid,
352 ) -> Result<i64> {
353 let row: (i64,) =
354 sqlx::query_as("SELECT COUNT(*) FROM follows WHERE target_type = $1 AND target_id = $2")
355 .bind(target_type)
356 .bind(target_id)
357 .fetch_one(pool)
358 .await?;
359
360 Ok(row.0)
361 }
362
363 /// Count unique followers who would receive a broadcast email from this creator
364 /// (followers of the user + followers of their projects, deduplicated).
365 #[tracing::instrument(skip_all)]
366 pub(crate) async fn get_broadcast_follower_count(pool: &PgPool, creator_id: UserId) -> Result<i64> {
367 let row: (i64,) = sqlx::query_as(
368 r"
369 SELECT COUNT(DISTINCT f.follower_id)
370 FROM follows f
371 JOIN users u ON u.id = f.follower_id
372 WHERE u.email_verified = true
373 AND u.suspended_at IS NULL
374 AND NOT EXISTS (SELECT 1 FROM email_suppressions es WHERE LOWER(es.email) = LOWER(u.email))
375 AND (
376 (f.target_type = 'user' AND f.target_id = $1)
377 OR (f.target_type = 'project' AND f.target_id IN (SELECT id FROM projects WHERE user_id = $1))
378 )
379 ",
380 )
381 .bind(creator_id)
382 .fetch_one(pool)
383 .await?;
384
385 Ok(row.0)
386 }
387
388 #[cfg(test)]
389 mod tests {
390 use super::*;
391
392 #[test]
393 fn follow_target_type_display() {
394 assert_eq!(FollowTargetType::User.to_string(), "user");
395 assert_eq!(FollowTargetType::Project.to_string(), "project");
396 assert_eq!(FollowTargetType::Tag.to_string(), "tag");
397 }
398
399 #[test]
400 fn follow_target_type_parse() {
401 assert_eq!(
402 "user".parse::<FollowTargetType>().unwrap(),
403 FollowTargetType::User
404 );
405 assert_eq!(
406 "project".parse::<FollowTargetType>().unwrap(),
407 FollowTargetType::Project
408 );
409 assert_eq!(
410 "tag".parse::<FollowTargetType>().unwrap(),
411 FollowTargetType::Tag
412 );
413 }
414
415 #[test]
416 fn follow_target_type_parse_invalid() {
417 assert!("invalid".parse::<FollowTargetType>().is_err());
418 assert!("User".parse::<FollowTargetType>().is_err());
419 assert!("".parse::<FollowTargetType>().is_err());
420 }
421
422 #[test]
423 fn follow_target_type_round_trip() {
424 for variant in [
425 FollowTargetType::User,
426 FollowTargetType::Project,
427 FollowTargetType::Tag,
428 ] {
429 let s = variant.to_string();
430 let parsed: FollowTargetType = s.parse().unwrap();
431 assert_eq!(variant, parsed);
432 }
433 }
434
435 #[test]
436 fn follower_email_row_constructible() {
437 let row = FollowerEmailRow {
438 id: UserId::new(),
439 email: "test@example.com".to_string(),
440 display_name: Some("Test User".to_string()),
441 };
442 assert_eq!(row.email, "test@example.com");
443 assert_eq!(row.display_name.as_deref(), Some("Test User"));
444 }
445
446 #[test]
447 fn follower_email_row_no_display_name() {
448 let row = FollowerEmailRow {
449 id: UserId::nil(),
450 email: "a@b.com".to_string(),
451 display_name: None,
452 };
453 assert!(row.display_name.is_none());
454 }
455
456 #[test]
457 fn followed_tag_ids_collection() {
458 // Mirrors the collect logic in get_followed_tag_ids
459 let uuids: Vec<(Uuid,)> = vec![(Uuid::new_v4(),), (Uuid::new_v4(),)];
460 let set: HashSet<Uuid> = uuids.iter().map(|r| r.0).collect();
461 assert_eq!(set.len(), 2);
462 }
463
464 #[test]
465 fn followed_tag_ids_dedup() {
466 let id = Uuid::new_v4();
467 let uuids: Vec<(Uuid,)> = vec![(id,), (id,)];
468 let set: HashSet<Uuid> = uuids.into_iter().map(|r| r.0).collect();
469 assert_eq!(set.len(), 1);
470 }
471
472 #[test]
473 fn user_id_equality() {
474 let id = UserId::new();
475 let same = UserId::from_uuid(*id.as_uuid());
476 assert_eq!(id, same);
477 }
478 }
479