const mongoose = require("mongoose"); 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 * Uses MongoDB transactions to ensure atomicity * * @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} * @param {Object} session - Optional MongoDB session for transaction * @returns {Promise} - Updated user and transaction */ async function awardPoints( userId, amount, type, description, relatedEntity = {}, session = null ) { const shouldEndSession = !session; const localSession = session || (await mongoose.startSession()); try { if (shouldEndSession) { localSession.startTransaction(); } // Get current user points const user = await User.findById(userId).session(localSession); if (!user) { throw new Error("User not found"); } // Calculate new balance const newBalance = Math.max(0, user.points + amount); // Create point transaction record const transaction = new PointTransaction({ user: userId, amount, type, description, relatedEntity, balanceAfter: newBalance, }); await transaction.save({ session: localSession }); // Update user points user.points = newBalance; await user.save({ session: localSession }); if (shouldEndSession) { await localSession.commitTransaction(); } return { user, transaction }; } catch (error) { if (shouldEndSession) { await localSession.abortTransaction(); } throw error; } finally { if (shouldEndSession) { localSession.endSession(); } } } /** * Award points for street adoption */ async function awardStreetAdoptionPoints(userId, streetId, session = null) { return awardPoints( userId, POINT_VALUES.STREET_ADOPTION, "street_adoption", "Adopted a street", { entityType: "Street", entityId: streetId }, session ); } /** * Award points for task completion */ async function awardTaskCompletionPoints(userId, taskId, session = null) { return awardPoints( userId, POINT_VALUES.TASK_COMPLETION, "task_completion", "Completed a task", { entityType: "Task", entityId: taskId }, session ); } /** * Award points for post creation */ async function awardPostCreationPoints(userId, postId, session = null) { return awardPoints( userId, POINT_VALUES.POST_CREATION, "post_creation", "Created a post", { entityType: "Post", entityId: postId }, session ); } /** * Award points for event participation */ async function awardEventParticipationPoints(userId, eventId, session = null) { return awardPoints( userId, POINT_VALUES.EVENT_PARTICIPATION, "event_participation", "Participated in an event", { entityType: "Event", entityId: eventId }, session ); } /** * Deduct points for reward redemption */ async function deductRewardPoints(userId, rewardId, amount, session = null) { return awardPoints( userId, -amount, "reward_redemption", "Redeemed a reward", { entityType: "Reward", entityId: rewardId }, session ); } /** * Check if a user has already earned a specific badge */ async function hasEarnedBadge(userId, badgeId) { const userBadge = await UserBadge.findOne({ user: userId, badge: badgeId }); return !!userBadge; } /** * Award a badge to a user * Prevents duplicate badge awards */ async function awardBadge(userId, badgeId, session = null) { const shouldEndSession = !session; const localSession = session || (await mongoose.startSession()); try { if (shouldEndSession) { localSession.startTransaction(); } // Check if badge already earned const existingBadge = await UserBadge.findOne({ user: userId, badge: badgeId, }).session(localSession); if (existingBadge) { if (shouldEndSession) { await localSession.commitTransaction(); } return { awarded: false, userBadge: existingBadge, isNew: false }; } // Award the badge const userBadge = new UserBadge({ user: userId, badge: badgeId, }); await userBadge.save({ session: localSession }); if (shouldEndSession) { await localSession.commitTransaction(); } return { awarded: true, userBadge, isNew: true }; } catch (error) { if (shouldEndSession) { await localSession.abortTransaction(); } throw error; } finally { if (shouldEndSession) { localSession.endSession(); } } } /** * Get user statistics for badge criteria checking */ async function getUserStats(userId) { const user = await User.findById(userId) .populate("adoptedStreets") .populate("completedTasks") .populate("posts") .populate("events"); if (!user) { throw new Error("User not found"); } return { streetAdoptions: user.adoptedStreets.length, taskCompletions: user.completedTasks.length, postCreations: user.posts.length, eventParticipations: user.events.length, pointsEarned: user.points, }; } /** * Check and award eligible badges for a user * This should be called after any action that might trigger badge eligibility * * @param {string} userId - User ID * @param {Object} session - Optional MongoDB session for transaction * @returns {Promise} - Array of newly awarded badges */ async function checkAndAwardBadges(userId, session = null) { try { // Get user stats const stats = await getUserStats(userId); // Get all badges const badges = await Badge.find().sort({ order: 1 }); const newlyAwardedBadges = []; // Check each badge's criteria for (const badge of badges) { const alreadyEarned = await hasEarnedBadge(userId, badge._id); if (!alreadyEarned && isBadgeEligible(stats, badge)) { const result = await awardBadge(userId, badge._id, session); if (result.awarded) { newlyAwardedBadges.push(badge); } } } return newlyAwardedBadges; } catch (error) { console.error("Error checking badges:", error); return []; } } /** * Check if user meets badge criteria */ function isBadgeEligible(stats, badge) { const { type, threshold } = badge.criteria; switch (type) { case "street_adoptions": return stats.streetAdoptions >= threshold; case "task_completions": return stats.taskCompletions >= threshold; case "post_creations": return stats.postCreations >= threshold; case "event_participations": return stats.eventParticipations >= threshold; case "points_earned": return stats.pointsEarned >= threshold; case "special": // Special badges require manual awarding return false; default: return false; } } /** * Get user's badge progress */ async function getUserBadgeProgress(userId) { try { const stats = await getUserStats(userId); const badges = await Badge.find().sort({ order: 1 }); const earnedBadges = await UserBadge.find({ user: userId }).populate( "badge" ); const earnedBadgeIds = new Set( earnedBadges.map((ub) => ub.badge._id.toString()) ); return badges.map((badge) => { const earned = earnedBadgeIds.has(badge._id.toString()); const eligible = isBadgeEligible(stats, badge); let progress = 0; const { type, threshold } = badge.criteria; switch (type) { case "street_adoptions": progress = Math.min(100, (stats.streetAdoptions / threshold) * 100); break; case "task_completions": progress = Math.min(100, (stats.taskCompletions / threshold) * 100); break; case "post_creations": progress = Math.min(100, (stats.postCreations / threshold) * 100); break; case "event_participations": progress = Math.min( 100, (stats.eventParticipations / threshold) * 100 ); break; case "points_earned": progress = Math.min(100, (stats.pointsEarned / threshold) * 100); break; default: progress = 0; } return { badge, earned, eligible, progress: Math.round(progress), currentValue: getCurrentValue(stats, type), targetValue: threshold, }; }); } catch (error) { console.error("Error getting badge progress:", error); return []; } } function getCurrentValue(stats, type) { switch (type) { case "street_adoptions": return stats.streetAdoptions; case "task_completions": return stats.taskCompletions; case "post_creations": return stats.postCreations; case "event_participations": return stats.eventParticipations; case "points_earned": return stats.pointsEarned; default: return 0; } } module.exports = { POINT_VALUES, awardPoints, awardStreetAdoptionPoints, awardTaskCompletionPoints, awardPostCreationPoints, awardEventParticipationPoints, deductRewardPoints, awardBadge, hasEarnedBadge, checkAndAwardBadges, getUserStats, getUserBadgeProgress, };