const User = require("../models/User"); const PointTransaction = require("../models/PointTransaction"); const Badge = require("../models/Badge"); const UserBadge = require("../models/UserBadge"); /** * Point rewards for different actions */ const POINT_VALUES = { STREET_ADOPTION: 100, TASK_COMPLETION: 50, POST_CREATION: 10, EVENT_PARTICIPATION: 75, }; /** * Awards points to a user with transaction tracking * * @param {string} userId - User ID * @param {number} amount - Points to award (can be negative for deductions) * @param {string} type - Transaction type (street_adoption, task_completion, etc.) * @param {string} description - Human-readable description * @param {Object} relatedEntity - Related entity {entityType, entityId} * @returns {Promise} - Updated user and transaction */ async function awardPoints( userId, amount, type, description, relatedEntity = {} ) { try { // Get current user const user = await User.findById(userId); if (!user) { throw new Error("User not found"); } // Calculate new balance const currentBalance = user.points || 0; const newBalance = currentBalance + amount; // Update user points const updatedUser = await User.update(userId, { points: newBalance }); // Create transaction record const transaction = await PointTransaction.create({ user: userId, amount: amount, transactionType: type, description: description, relatedEntity: relatedEntity, balanceAfter: newBalance, }); // Check for new badges await checkAndAwardBadges(userId, newBalance); return { user: updatedUser, transaction: transaction, newBalance: newBalance, }; } catch (error) { console.error("Error awarding points:", error); throw error; } } /** * Get user's current point balance */ async function getUserPoints(userId) { try { const user = await User.findById(userId); return user ? user.points || 0 : 0; } catch (error) { console.error("Error getting user points:", error); throw error; } } /** * Get user's transaction history */ async function getUserTransactionHistory(userId, limit = 50, skip = 0) { try { return await PointTransaction.findByUser(userId, limit, skip); } catch (error) { console.error("Error getting transaction history:", error); throw error; } } /** * Check if user qualifies for new badges and award them */ async function checkAndAwardBadges(userId, userPoints = null) { try { // Get user's current points if not provided if (userPoints === null) { userPoints = await getUserPoints(userId); } // Get user's stats for badge checking const userStats = await getUserStats(userId); const userBadges = await UserBadge.findByUser(userId); // Get all available badges const allBadges = await Badge.findAll(); // Check each badge criteria for (const badge of allBadges) { // Skip if user already has this badge if (userBadges.some(ub => ub.badge?._id === badge._id || ub.badge === badge._id)) { continue; } let qualifies = false; // Check different badge criteria switch (badge.criteria?.type) { case 'points_earned': qualifies = userPoints >= badge.criteria.threshold; break; case 'street_adoptions': qualifies = userStats.streetAdoptions >= badge.criteria.threshold; break; case 'task_completions': qualifies = userStats.tasksCompleted >= badge.criteria.threshold; break; case 'post_creations': qualifies = userStats.postsCreated >= badge.criteria.threshold; break; case 'event_participations': qualifies = userStats.eventsParticipated >= badge.criteria.threshold; break; case 'consecutive_days': qualifies = userStats.consecutiveDays >= badge.criteria.threshold; break; case 'special': // Special badges are awarded manually qualifies = false; break; } if (qualifies) { await awardBadge(userId, badge._id); } } } catch (error) { console.error("Error checking badges:", error); throw error; } } /** * Award a specific badge to a user */ async function awardBadge(userId, badgeId) { try { // Get badge details const badge = await Badge.findById(badgeId); if (!badge) { throw new Error("Badge not found"); } // Create user badge record const userBadge = await UserBadge.create({ user: userId, badge: badgeId, earnedAt: new Date().toISOString(), }); // Award points for earning badge (if it's a rare or higher badge) let pointsAwarded = 0; if (badge.rarity === 'rare') { pointsAwarded = 50; } else if (badge.rarity === 'epic') { pointsAwarded = 100; } else if (badge.rarity === 'legendary') { pointsAwarded = 200; } if (pointsAwarded > 0) { await awardPoints( userId, pointsAwarded, 'badge_earned', `Earned ${badge.name} badge`, { entityType: 'Badge', entityId: badgeId } ); } return userBadge; } catch (error) { console.error("Error awarding badge:", error); throw error; } } /** * Get user statistics for badge checking */ async function getUserStats(userId) { try { const user = await User.findById(userId); if (!user) { throw new Error("User not found"); } return { points: user.points || 0, streetsAdopted: user.stats?.streetsAdopted || 0, tasksCompleted: user.stats?.tasksCompleted || 0, postsCreated: user.stats?.postsCreated || 0, eventsParticipated: user.stats?.eventsParticipated || 0, badgesEarned: user.stats?.badgesEarned || 0, consecutiveDays: user.stats?.consecutiveDays || 0, }; } catch (error) { console.error("Error getting user stats:", error); throw error; } } /** * Get user's badge progress for all badges (earned and unearned) */ async function getUserBadgeProgress(userId) { try { const allBadges = await Badge.findAll(); const userStats = await getUserStats(userId); const userEarnedBadges = await UserBadge.findByUser(userId); const badgeProgress = allBadges.map(badge => { const earnedBadge = userEarnedBadges.find(ub => ub.badge?._id === badge._id || ub.badge === badge._id); const isEarned = !!earnedBadge; let progress = 0; let threshold = badge.criteria?.threshold || 0; if (isEarned) { progress = threshold; // If earned, progress is full } else if (badge.criteria?.type) { switch (badge.criteria.type) { case 'points_earned': progress = userStats.points || 0; break; case 'street_adoptions': progress = userStats.streetsAdopted; break; case 'task_completions': progress = userStats.tasksCompleted; break; case 'post_creations': progress = userStats.postsCreated; break; case 'event_participations': progress = userStats.eventsParticipated; break; case 'consecutive_days': progress = userStats.consecutiveDays; break; case 'special': progress = 0; // Special badges have no progress bar threshold = 1; break; default: progress = 0; } } // Ensure progress doesn't exceed threshold progress = Math.min(progress, threshold); return { ...badge, isEarned, progress, threshold, earnedAt: isEarned ? earnedBadge.earnedAt : null, }; }); return badgeProgress; } catch (error) { console.error("Error getting user badge progress:", error); throw error; } } /** * Get user's badges */ async function getUserBadges(userId) { try { const userBadges = await UserBadge.findByUser(userId); const badges = []; for (const userBadge of userBadges) { const badgeData = userBadge.badge; // If badge is already populated (object), use it; otherwise fetch it const badge = typeof badgeData === 'object' && badgeData._id ? badgeData : await Badge.findById(badgeData); if (badge) { badges.push({ ...badge, earnedAt: userBadge.earnedAt, }); } } return badges; } catch (error) { console.error("Error getting user badges:", error); throw error; } } /** * Redeem points for a reward */ async function redeemPoints(userId, rewardId, pointsCost) { try { const currentPoints = await getUserPoints(userId); if (currentPoints < pointsCost) { throw new Error("Insufficient points"); } // Deduct points const result = await awardPoints( userId, -pointsCost, 'reward_redemption', `Redeemed reward ${rewardId}`, { entityType: 'Reward', entityId: rewardId } ); return result; } catch (error) { console.error("Error redeeming points:", error); throw error; } } /** * Get leaderboard */ async function getLeaderboard(limit = 10) { try { // This would typically use a more efficient query const users = await User.findAll(); // Sort by points (this should be done at database level for efficiency) const sortedUsers = users .filter(user => user.points > 0) .sort((a, b) => b.points - a.points) .slice(0, limit); return sortedUsers.map((user, index) => ({ rank: index + 1, userId: user._id, username: user.username, points: user.points, badges: [], // Would populate if needed })); } catch (error) { console.error("Error getting leaderboard:", error); throw error; } } /** * Get global leaderboard (all time) * @param {number} limit - Number of users to return * @param {number} offset - Offset for pagination * @returns {Promise} Leaderboard data */ async function getGlobalLeaderboard(limit = 100, offset = 0) { try { const couchdbService = require("./couchdbService"); const Street = require("../models/Street"); const Task = require("../models/Task"); // Get all users sorted by points const result = await couchdbService.find({ selector: { type: "user", points: { $gt: 0 } }, sort: [{ points: "desc" }], limit: limit, skip: offset }); const users = Array.isArray(result) ? result : []; // Enrich with stats and badges const leaderboard = await Promise.all(users.map(async (user, index) => { // Get user badges const userBadgesRaw = await UserBadge.findByUser(user._id); const userBadges = Array.isArray(userBadgesRaw) ? userBadgesRaw : []; const badges = await Promise.all(userBadges.map(async (ub) => { const badgeData = ub.badge; const badge = typeof badgeData === 'object' && badgeData._id ? badgeData : await Badge.findById(badgeData); return badge ? { _id: badge._id, name: badge.name, icon: badge.icon, rarity: badge.rarity } : null; })); return { rank: offset + index + 1, userId: user._id, username: user.name, avatar: user.profilePicture || null, points: user.points || 0, streetsAdopted: user.stats?.streetsAdopted || user.adoptedStreets?.length || 0, tasksCompleted: user.stats?.tasksCompleted || user.completedTasks?.length || 0, badges: badges.filter(b => b !== null) }; })); return leaderboard; } catch (error) { console.error("Error getting global leaderboard:", error); throw error; } } /** * Get weekly leaderboard * @param {number} limit - Number of users to return * @param {number} offset - Offset for pagination * @returns {Promise} Leaderboard data */ async function getWeeklyLeaderboard(limit = 100, offset = 0) { try { const couchdbService = require("./couchdbService"); // Calculate start of week (Monday 00:00:00) const now = new Date(); const dayOfWeek = now.getDay(); const daysToMonday = dayOfWeek === 0 ? 6 : dayOfWeek - 1; const startOfWeek = new Date(now); startOfWeek.setDate(now.getDate() - daysToMonday); startOfWeek.setHours(0, 0, 0, 0); // Get all point transactions since start of week const txResult = await couchdbService.find({ selector: { type: "point_transaction", createdAt: { $gte: startOfWeek.toISOString() } } }); const transactions = Array.isArray(txResult) ? txResult : []; // Aggregate points by user const userPointsMap = {}; transactions.forEach(transaction => { if (!userPointsMap[transaction.user]) { userPointsMap[transaction.user] = 0; } userPointsMap[transaction.user] += transaction.amount; }); // Convert to array and sort const userPoints = Object.entries(userPointsMap) .map(([userId, points]) => ({ userId, points })) .filter(entry => entry.points > 0) .sort((a, b) => b.points - a.points) .slice(offset, offset + limit); // Enrich with user data const leaderboard = await Promise.all(userPoints.map(async (entry, index) => { const user = await User.findById(entry.userId); if (!user) return null; // Get user badges const userBadgesRaw = await UserBadge.findByUser(user._id); const userBadges = Array.isArray(userBadgesRaw) ? userBadgesRaw : []; const badges = await Promise.all(userBadges.map(async (ub) => { const badgeData = ub.badge; const badge = typeof badgeData === 'object' && badgeData._id ? badgeData : await Badge.findById(badgeData); return badge ? { _id: badge._id, name: badge.name, icon: badge.icon, rarity: badge.rarity } : null; })); return { rank: offset + index + 1, userId: user._id, username: user.name, avatar: user.profilePicture || null, points: entry.points, streetsAdopted: user.stats?.streetsAdopted || user.adoptedStreets?.length || 0, tasksCompleted: user.stats?.tasksCompleted || user.completedTasks?.length || 0, badges: badges.filter(b => b !== null) }; })); return leaderboard.filter(entry => entry !== null); } catch (error) { console.error("Error getting weekly leaderboard:", error); throw error; } } /** * Get monthly leaderboard * @param {number} limit - Number of users to return * @param {number} offset - Offset for pagination * @returns {Promise} Leaderboard data */ async function getMonthlyLeaderboard(limit = 100, offset = 0) { try { const couchdbService = require("./couchdbService"); // Calculate start of month const now = new Date(); const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1); // Get all point transactions since start of month const txResult = await couchdbService.find({ selector: { type: "point_transaction", createdAt: { $gte: startOfMonth.toISOString() } } }); const transactions = Array.isArray(txResult) ? txResult : []; // Aggregate points by user const userPointsMap = {}; transactions.forEach(transaction => { if (!userPointsMap[transaction.user]) { userPointsMap[transaction.user] = 0; } userPointsMap[transaction.user] += transaction.amount; }); // Convert to array and sort const userPoints = Object.entries(userPointsMap) .map(([userId, points]) => ({ userId, points })) .filter(entry => entry.points > 0) .sort((a, b) => b.points - a.points) .slice(offset, offset + limit); // Enrich with user data const leaderboard = await Promise.all(userPoints.map(async (entry, index) => { const user = await User.findById(entry.userId); if (!user) return null; // Get user badges const userBadgesRaw = await UserBadge.findByUser(user._id); const userBadges = Array.isArray(userBadgesRaw) ? userBadgesRaw : []; const badges = await Promise.all(userBadges.slice(0, 5).map(async (ub) => { const badgeData = ub.badge; const badge = typeof badgeData === 'object' && badgeData._id ? badgeData : await Badge.findById(badgeData); return badge ? { _id: badge._id, name: badge.name, icon: badge.icon, rarity: badge.rarity } : null; })); return { rank: offset + index + 1, userId: user._id, username: user.name, avatar: user.profilePicture || null, points: entry.points, streetsAdopted: user.stats?.streetsAdopted || user.adoptedStreets?.length || 0, tasksCompleted: user.stats?.tasksCompleted || user.completedTasks?.length || 0, badges: badges.filter(b => b !== null) }; })); return leaderboard.filter(entry => entry !== null); } catch (error) { console.error("Error getting monthly leaderboard:", error); throw error; } } /** * Get friends leaderboard * @param {string} userId - User ID * @param {number} limit - Number of users to return * @param {number} offset - Offset for pagination * @returns {Promise} Leaderboard data */ async function getFriendsLeaderboard(userId, limit = 100, offset = 0) { try { const user = await User.findById(userId); if (!user) { throw new Error("User not found"); } // For now, return empty array as friends system isn't implemented // In future, would get user's friends list and filter leaderboard const friendIds = Array.isArray(user.friends) ? user.friends : []; if (friendIds.length === 0) { // Include self if no friends friendIds.push(userId); } const couchdbService = require("./couchdbService"); // Get friends' data const friends = await couchdbService.find({ selector: { type: "user", _id: { $in: friendIds } } }); // Sort by points const sortedFriends = friends .sort((a, b) => (b.points || 0) - (a.points || 0)) .slice(offset, offset + limit); // Enrich with badges const leaderboard = await Promise.all(sortedFriends.map(async (friend, index) => { const userBadges = await UserBadge.findByUser(friend._id); const badges = await Promise.all(userBadges.slice(0, 5).map(async (ub) => { const badgeData = ub.badge; const badge = typeof badgeData === 'object' && badgeData._id ? badgeData : await Badge.findById(badgeData); return badge ? { _id: badge._id, name: badge.name, icon: badge.icon, rarity: badge.rarity } : null; })); return { rank: offset + index + 1, userId: friend._id, username: friend.name, avatar: friend.profilePicture || null, points: friend.points || 0, streetsAdopted: friend.stats?.streetsAdopted || friend.adoptedStreets?.length || 0, tasksCompleted: friend.stats?.tasksCompleted || friend.completedTasks?.length || 0, badges: badges.filter(b => b !== null), isFriend: true }; })); return leaderboard; } catch (error) { console.error("Error getting friends leaderboard:", error); throw error; } } /** * Get user's leaderboard position * @param {string} userId - User ID * @param {string} timeframe - 'all', 'week', or 'month' * @returns {Promise} User's position data */ async function getUserLeaderboardPosition(userId, timeframe = "all") { try { const user = await User.findById(userId); if (!user) { return null; } let rank = 0; let totalUsers = 0; let userPoints = 0; const couchdbService = require("./couchdbService"); if (timeframe === "all") { // Get all users with points const allUsers = await couchdbService.find({ selector: { type: "user", points: { $gt: 0 } }, sort: [{ points: "desc" }] }); totalUsers = allUsers.length; userPoints = user.points || 0; // Find user's rank rank = allUsers.findIndex(u => u._id === userId) + 1; } else if (timeframe === "week" || timeframe === "month") { // Calculate start date const now = new Date(); let startDate; if (timeframe === "week") { const dayOfWeek = now.getDay(); const daysToMonday = dayOfWeek === 0 ? 6 : dayOfWeek - 1; startDate = new Date(now); startDate.setDate(now.getDate() - daysToMonday); startDate.setHours(0, 0, 0, 0); } else { startDate = new Date(now.getFullYear(), now.getMonth(), 1); } // Get all transactions for period const transactions = await couchdbService.find({ selector: { type: "point_transaction", createdAt: { $gte: startDate.toISOString() } } }); // Aggregate points by user const userPointsMap = {}; transactions.forEach(transaction => { if (!userPointsMap[transaction.user]) { userPointsMap[transaction.user] = 0; } userPointsMap[transaction.user] += transaction.amount; }); // Sort users by points const sortedUsers = Object.entries(userPointsMap) .filter(([_, points]) => points > 0) .sort((a, b) => b[1] - a[1]); totalUsers = sortedUsers.length; userPoints = userPointsMap[userId] || 0; rank = sortedUsers.findIndex(([id, _]) => id === userId) + 1; } // Get user badges const userBadges = await UserBadge.findByUser(user._id); const badges = await Promise.all(userBadges.slice(0, 5).map(async (ub) => { const badgeData = ub.badge; const badge = typeof badgeData === 'object' && badgeData._id ? badgeData : await Badge.findById(badgeData); return badge ? { _id: badge._id, name: badge.name, icon: badge.icon, rarity: badge.rarity } : null; })); return { rank: rank || null, totalUsers, userId: user._id, username: user.name, avatar: user.profilePicture || null, points: userPoints, streetsAdopted: user.stats?.streetsAdopted || user.adoptedStreets?.length || 0, tasksCompleted: user.stats?.tasksCompleted || user.completedTasks?.length || 0, badges: badges.filter(b => b !== null), percentile: totalUsers > 0 ? Math.round((1 - (rank - 1) / totalUsers) * 100) : 0 }; } catch (error) { console.error("Error getting user leaderboard position:", error); throw error; } } /** * Get leaderboard statistics * @returns {Promise} Statistics data */ async function getLeaderboardStats() { try { const couchdbService = require("./couchdbService"); // Get all users with points const allUsers = await couchdbService.find({ selector: { type: "user", points: { $gt: 0 } } }); // Calculate statistics const totalUsers = allUsers.length; const totalPoints = allUsers.reduce((sum, user) => sum + (user.points || 0), 0); const avgPoints = totalUsers > 0 ? Math.round(totalPoints / totalUsers) : 0; const maxPoints = allUsers.length > 0 ? Math.max(...allUsers.map(u => u.points || 0)) : 0; const minPoints = allUsers.length > 0 ? Math.min(...allUsers.map(u => u.points || 0)) : 0; // Get weekly stats const now = new Date(); const dayOfWeek = now.getDay(); const daysToMonday = dayOfWeek === 0 ? 6 : dayOfWeek - 1; const startOfWeek = new Date(now); startOfWeek.setDate(now.getDate() - daysToMonday); startOfWeek.setHours(0, 0, 0, 0); const weeklyTransactions = await couchdbService.find({ selector: { type: "point_transaction", createdAt: { $gte: startOfWeek.toISOString() } } }); const weeklyPoints = weeklyTransactions.reduce((sum, t) => sum + (t.amount || 0), 0); const activeUsersThisWeek = new Set(weeklyTransactions.map(t => t.user)).size; return { totalUsers, totalPoints, avgPoints, maxPoints, minPoints, weeklyStats: { totalPoints: weeklyPoints, activeUsers: activeUsersThisWeek, transactions: weeklyTransactions.length } }; } catch (error) { console.error("Error getting leaderboard statistics:", error); throw error; } } module.exports = { awardPoints, getUserPoints, getUserTransactionHistory, checkAndAwardBadges, awardBadge, getUserBadges, getUserBadgeProgress, getUserStats, redeemPoints, getLeaderboard, getGlobalLeaderboard, getWeeklyLeaderboard, getMonthlyLeaderboard, getFriendsLeaderboard, getUserLeaderboardPosition, getLeaderboardStats, POINT_VALUES, };