//! Follow system: users can follow other users and projects. use std::collections::HashSet; use sqlx::PgPool; use uuid::Uuid; use super::enums::FollowTargetType; use super::models::FollowerExportRow; use super::UserId; use crate::error::Result; /// Follow a target (user or project). Idempotent; does nothing if already following. #[tracing::instrument(skip_all)] pub 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 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 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) } /// Get recent public items from all users and projects this user follows. /// Returns up to 50 items, newest first. #[tracing::instrument(skip_all)] pub async fn get_followed_items( pool: &PgPool, follower_id: UserId, ) -> Result> { let items = sqlx::query_as::<_, super::models::DbItem>( r#" SELECT DISTINCT i.* FROM items i JOIN projects p ON i.project_id = p.id WHERE i.is_public = true AND p.is_public = true AND ( -- Items from followed users p.user_id IN ( SELECT target_id FROM follows WHERE follower_id = $1 AND target_type = 'user' ) OR -- Items from followed projects p.id IN ( SELECT target_id FROM follows WHERE follower_id = $1 AND target_type = 'project' ) OR -- Items with followed tags i.id IN ( SELECT it.item_id FROM item_tags it WHERE it.tag_id IN ( SELECT target_id FROM follows WHERE follower_id = $1 AND target_type = 'tag' ) ) ) 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 async fn get_followed_feed_items( pool: &PgPool, follower_id: UserId, limit: i64, offset: i64, ) -> Result> { let items = sqlx::query_as::<_, super::models::DbDiscoverItemRow>( r#" SELECT DISTINCT i.id, i.title, i.description, i.price_cents, i.item_type, i.created_at, u.username, p.title as project_title, i.sales_count::bigint, pt.name as primary_tag_name, i.pwyw_enabled, i.pwyw_min_cents, NULL::real as match_score, i.ai_tier FROM items i 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 WHERE i.is_public = true AND p.is_public = true AND ( p.user_id IN ( SELECT target_id FROM follows WHERE follower_id = $1 AND target_type = 'user' ) OR p.id IN ( SELECT target_id FROM follows WHERE follower_id = $1 AND target_type = 'project' ) OR i.id IN ( SELECT it.item_id FROM item_tags it WHERE it.tag_id IN ( SELECT target_id FROM follows WHERE follower_id = $1 AND target_type = 'tag' ) ) ) 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 async fn count_followed_feed_items( pool: &PgPool, follower_id: UserId, ) -> Result { let count: i64 = sqlx::query_scalar( r#" SELECT COUNT(DISTINCT i.id) FROM items i JOIN projects p ON i.project_id = p.id WHERE i.is_public = true AND p.is_public = true AND ( p.user_id IN ( SELECT target_id FROM follows WHERE follower_id = $1 AND target_type = 'user' ) OR p.id IN ( SELECT target_id FROM follows WHERE follower_id = $1 AND target_type = 'project' ) OR i.id IN ( SELECT it.item_id FROM item_tags it WHERE it.tag_id IN ( SELECT target_id FROM follows WHERE follower_id = $1 AND target_type = 'tag' ) ) ) "#, ) .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)] pub async fn get_followed_tag_ids(pool: &PgPool, follower_id: UserId) -> Result> { let rows: Vec<(Uuid,)> = sqlx::query_as( "SELECT target_id FROM follows WHERE follower_id = $1 AND target_type = 'tag'", ) .bind(follower_id) .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)] pub async fn get_followers_for_export( pool: &PgPool, user_id: UserId, ) -> 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 "#, ) .bind(user_id) .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 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 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 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 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); } }