//! Follow system: users can follow other users and projects. use std::collections::HashSet; use sqlx::PgPool; use uuid::Uuid; use super::UserId; use super::enums::FollowTargetType; use super::models::FollowerExportRow; use crate::error::Result; /// Follow a target (user or project). Idempotent; does nothing if already following. #[tracing::instrument(skip_all)] pub(crate) async fn follow( pool: &PgPool, follower_id: UserId, target_type: FollowTargetType, target_id: Uuid, ) -> Result<()> { sqlx::query( r" INSERT INTO follows (follower_id, target_type, target_id) VALUES ($1, $2, $3) ON CONFLICT (follower_id, target_type, target_id) DO NOTHING ", ) .bind(follower_id) .bind(target_type) .bind(target_id) .execute(pool) .await?; Ok(()) } /// Unfollow a target. Returns true if a row was deleted. #[tracing::instrument(skip_all)] pub(crate) async fn unfollow( pool: &PgPool, follower_id: UserId, target_type: FollowTargetType, target_id: Uuid, ) -> Result { let result = sqlx::query( "DELETE FROM follows WHERE follower_id = $1 AND target_type = $2 AND target_id = $3", ) .bind(follower_id) .bind(target_type) .bind(target_id) .execute(pool) .await?; Ok(result.rows_affected() > 0) } /// Check if a user is following a target. #[tracing::instrument(skip_all)] pub(crate) async fn is_following( pool: &PgPool, follower_id: UserId, target_type: FollowTargetType, target_id: Uuid, ) -> Result { let row: (bool,) = sqlx::query_as( "SELECT EXISTS(SELECT 1 FROM follows WHERE follower_id = $1 AND target_type = $2 AND target_id = $3)", ) .bind(follower_id) .bind(target_type) .bind(target_id) .fetch_one(pool) .await?; Ok(row.0) } /// The set of item ids visible in a follower's feed: everything from a followed /// user, a followed project, or a followed tag. `$1` is the follower. /// /// Tag following is HIERARCHICAL, following a branch follows everything under /// it, matching how the discover tag filter has always behaved /// (`db::discover::TAG_CLAUSE`). The two used to disagree while sitting next to /// each other in the drill-down: the filter expanded via `path LIKE`, following /// joined `follows.target_id = item_tags.tag_id` exactly. Since `item_tags` /// holds only directly-assigned tags and the write path enforces `depth >= 3`, /// following any depth-1 or depth-2 tag matched nothing, forever. /// /// The descendant match is `t.path LIKE ft.path || '.%'` rather than a /// `tagtree::like_descendant_pattern` bind, because the followed paths are not /// known host-side here, they come out of `follows` in the same query. That is /// safe only because `tagtree` validation restricts a path to lowercase /// alphanumerics, hyphens, and dots, so `escape_like` is a no-op on every value /// that can reach this column. If that charset ever widens, this must become a /// bound pattern built by `tagtree::like_descendant_pattern`. /// /// Shared verbatim by the three feed queries below so the three cannot drift. const FOLLOWED_ITEM_IDS_CTE: &str = r" WITH followed_item_ids AS ( SELECT i.id FROM items i JOIN projects p ON i.project_id = p.id JOIN users u ON u.id = p.user_id JOIN follows f ON f.follower_id = $1 AND f.target_type = 'user' AND f.target_id = p.user_id 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 UNION SELECT i.id FROM items i JOIN projects p ON i.project_id = p.id JOIN users u ON u.id = p.user_id JOIN follows f ON f.follower_id = $1 AND f.target_type = 'project' AND f.target_id = p.id 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 UNION SELECT i.id FROM items i JOIN projects p ON i.project_id = p.id JOIN users u ON u.id = p.user_id JOIN item_tags it ON it.item_id = i.id JOIN tags t ON t.id = it.tag_id JOIN follows f ON f.follower_id = $1 AND f.target_type = 'tag' JOIN tags ft ON ft.id = f.target_id WHERE (t.id = ft.id OR t.path LIKE ft.path || '.%') 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 ) "; /// Get recent public items from all users and projects this user follows. /// Returns up to 50 items, newest first. #[tracing::instrument(skip_all)] pub(crate) async fn get_followed_items( pool: &PgPool, follower_id: UserId, ) -> Result> { let items = sqlx::query_as::<_, super::models::DbItem>(&format!( r"{FOLLOWED_ITEM_IDS_CTE} SELECT i.* FROM items i JOIN followed_item_ids fi ON fi.id = i.id ORDER BY i.created_at DESC LIMIT 50 " )) .bind(follower_id) .fetch_all(pool) .await?; Ok(items) } /// Get paginated feed items from all users, projects, and tags this user follows. /// Returns discover-style rows for consistent template rendering. #[tracing::instrument(skip_all)] pub(crate) async fn get_followed_feed_items( pool: &PgPool, follower_id: UserId, limit: i64, offset: i64, ) -> Result> { let items = sqlx::query_as::<_, super::models::DbDiscoverItemRow>(&format!( r"{FOLLOWED_ITEM_IDS_CTE} SELECT i.id, i.title, i.description, i.price_cents, i.item_type, i.created_at, u.username, u.settlement_currency, p.title as project_title, i.sales_count::bigint, pt.name as primary_tag_name, i.pwyw_enabled, i.pwyw_min_cents, NULL::smallint as match_tier, NULL::int as matched_terms, NULL::int as query_terms, NULL::real as match_score, i.ai_tier FROM items i JOIN followed_item_ids fi ON fi.id = i.id JOIN projects p ON i.project_id = p.id JOIN users u ON p.user_id = u.id LEFT JOIN item_tags pit ON pit.item_id = i.id AND pit.is_primary = true LEFT JOIN tags pt ON pt.id = pit.tag_id ORDER BY i.created_at DESC LIMIT $2 OFFSET $3 " )) .bind(follower_id) .bind(limit) .bind(offset) .fetch_all(pool) .await?; Ok(items) } /// Count total feed items from all followed users, projects, and tags. #[tracing::instrument(skip_all)] pub(crate) async fn count_followed_feed_items(pool: &PgPool, follower_id: UserId) -> Result { let count: i64 = sqlx::query_scalar(&format!( r"{FOLLOWED_ITEM_IDS_CTE} SELECT COUNT(*) FROM followed_item_ids " )) .bind(follower_id) .fetch_one(pool) .await?; Ok(count) } /// Get all tag IDs that a user follows, for batch lookup on the discover page. #[tracing::instrument(skip_all)] /// Which of `tag_ids` the follower follows. Scoped variant of /// `get_followed_tag_ids`: the discover facet sidebar only renders ~10 tags and /// tests membership for those, so fetching the viewer's entire followed-tag set /// per render was an over-fetch. Single `= ANY($2)` /// roundtrip. Returns an empty set for an empty input. pub(crate) async fn following_subset( pool: &PgPool, follower_id: UserId, tag_ids: &[crate::db::TagId], ) -> Result> { if tag_ids.is_empty() { return Ok(HashSet::new()); } let rows: Vec<(crate::db::TagId,)> = sqlx::query_as( "SELECT target_id FROM follows \ WHERE follower_id = $1 AND target_type = 'tag' AND target_id = ANY($2)", ) .bind(follower_id) .bind(tag_ids) .fetch_all(pool) .await?; Ok(rows.into_iter().map(|r| r.0).collect()) } /// Export all followers of a user (direct user followers + project followers). /// /// Returns username, display_name, what they follow (user/project), and when. #[tracing::instrument(skip_all)] /// One page of a creator's followers for CSV export, newest first. /// /// Paginated (`LIMIT`/`OFFSET`) so the export streams in bounded batches rather /// than materializing every follower in one query, and so the per-row email /// `EXISTS` reveal runs only for a bounded page at a time. /// Stable `(created_at, follower_id)` ordering keeps OFFSET batches consistent. /// How many rows the follower export will page through. /// /// The same set `get_followers_for_export_page` walks, counted rather than /// paged. Asked once before the export starts, so `follower_exports` can record /// what was about to be handed over (`159a7a20`). #[tracing::instrument(skip_all)] pub(crate) async fn count_followers_for_export(pool: &PgPool, user_id: UserId) -> Result { let count = sqlx::query_scalar::<_, i64>( r" SELECT COUNT(*) FROM follows f WHERE (f.target_type = 'user' AND f.target_id = $1) OR (f.target_type = 'project' AND f.target_id IN (SELECT id FROM projects WHERE user_id = $1)) ", ) .bind(user_id) .fetch_one(pool) .await?; Ok(count) } pub(crate) async fn get_followers_for_export_page( pool: &PgPool, user_id: UserId, limit: i64, offset: i64, ) -> Result> { let rows = sqlx::query_as::<_, FollowerExportRow>( r" SELECT u.username, u.display_name, f.target_type, f.created_at, CASE WHEN EXISTS ( SELECT 1 FROM transactions t WHERE t.buyer_id = f.follower_id AND t.seller_id = $1 AND t.status = 'completed' AND t.share_contact = true AND NOT EXISTS ( SELECT 1 FROM contact_revocations cr WHERE cr.buyer_id = f.follower_id AND cr.seller_id = $1 ) ) THEN u.email ELSE NULL END AS email FROM follows f JOIN users u ON u.id = f.follower_id WHERE (f.target_type = 'user' AND f.target_id = $1) OR (f.target_type = 'project' AND f.target_id IN (SELECT id FROM projects WHERE user_id = $1)) ORDER BY f.created_at DESC, f.follower_id DESC LIMIT $2 OFFSET $3 ", ) .bind(user_id) .bind(limit) .bind(offset) .fetch_all(pool) .await?; Ok(rows) } /// Email address, display name, and user ID of a follower (for broadcast/notification). #[derive(Debug, Clone, sqlx::FromRow)] pub(crate) struct FollowerEmailRow { pub id: UserId, pub email: String, pub display_name: Option, } /// Get deduplicated email addresses of all followers (user + project follows). /// Only includes verified, non-suspended users. Excludes suppressed addresses. /// Capped at 10,000. #[tracing::instrument(skip_all)] pub(crate) async fn get_follower_emails( pool: &PgPool, creator_id: UserId, ) -> Result> { let rows = sqlx::query_as::<_, FollowerEmailRow>( r" SELECT DISTINCT u.id, u.email, u.display_name FROM follows f JOIN users u ON u.id = f.follower_id WHERE u.email_verified = true AND u.suspended_at IS NULL AND NOT EXISTS (SELECT 1 FROM email_suppressions es WHERE LOWER(es.email) = LOWER(u.email)) AND ( (f.target_type = 'user' AND f.target_id = $1) OR (f.target_type = 'project' AND f.target_id IN (SELECT id FROM projects WHERE user_id = $1)) ) LIMIT 10000 ", ) .bind(creator_id) .fetch_all(pool) .await?; Ok(rows) } /// Get the follower count for a target. #[tracing::instrument(skip_all)] pub(crate) async fn get_follower_count( pool: &PgPool, target_type: FollowTargetType, target_id: Uuid, ) -> Result { let row: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM follows WHERE target_type = $1 AND target_id = $2") .bind(target_type) .bind(target_id) .fetch_one(pool) .await?; Ok(row.0) } /// Count unique followers who would receive a broadcast email from this creator /// (followers of the user + followers of their projects, deduplicated). #[tracing::instrument(skip_all)] pub(crate) async fn get_broadcast_follower_count(pool: &PgPool, creator_id: UserId) -> Result { let row: (i64,) = sqlx::query_as( r" SELECT COUNT(DISTINCT f.follower_id) FROM follows f JOIN users u ON u.id = f.follower_id WHERE u.email_verified = true AND u.suspended_at IS NULL AND NOT EXISTS (SELECT 1 FROM email_suppressions es WHERE LOWER(es.email) = LOWER(u.email)) AND ( (f.target_type = 'user' AND f.target_id = $1) OR (f.target_type = 'project' AND f.target_id IN (SELECT id FROM projects WHERE user_id = $1)) ) ", ) .bind(creator_id) .fetch_one(pool) .await?; Ok(row.0) } #[cfg(test)] mod tests { use super::*; #[test] fn follow_target_type_display() { assert_eq!(FollowTargetType::User.to_string(), "user"); assert_eq!(FollowTargetType::Project.to_string(), "project"); assert_eq!(FollowTargetType::Tag.to_string(), "tag"); } #[test] fn follow_target_type_parse() { assert_eq!( "user".parse::().unwrap(), FollowTargetType::User ); assert_eq!( "project".parse::().unwrap(), FollowTargetType::Project ); assert_eq!( "tag".parse::().unwrap(), FollowTargetType::Tag ); } #[test] fn follow_target_type_parse_invalid() { assert!("invalid".parse::().is_err()); assert!("User".parse::().is_err()); assert!("".parse::().is_err()); } #[test] fn follow_target_type_round_trip() { for variant in [ FollowTargetType::User, FollowTargetType::Project, FollowTargetType::Tag, ] { let s = variant.to_string(); let parsed: FollowTargetType = s.parse().unwrap(); assert_eq!(variant, parsed); } } #[test] fn follower_email_row_constructible() { let row = FollowerEmailRow { id: UserId::new(), email: "test@example.com".to_string(), display_name: Some("Test User".to_string()), }; assert_eq!(row.email, "test@example.com"); assert_eq!(row.display_name.as_deref(), Some("Test User")); } #[test] fn follower_email_row_no_display_name() { let row = FollowerEmailRow { id: UserId::nil(), email: "a@b.com".to_string(), display_name: None, }; assert!(row.display_name.is_none()); } #[test] fn followed_tag_ids_collection() { // Mirrors the collect logic in get_followed_tag_ids let uuids: Vec<(Uuid,)> = vec![(Uuid::new_v4(),), (Uuid::new_v4(),)]; let set: HashSet = uuids.iter().map(|r| r.0).collect(); assert_eq!(set.len(), 2); } #[test] fn followed_tag_ids_dedup() { let id = Uuid::new_v4(); let uuids: Vec<(Uuid,)> = vec![(id,), (id,)]; let set: HashSet = uuids.into_iter().map(|r| r.0).collect(); assert_eq!(set.len(), 1); } #[test] fn user_id_equality() { let id = UserId::new(); let same = UserId::from_uuid(*id.as_uuid()); assert_eq!(id, same); } }