feat(backend): implement complete gamification system
Implement comprehensive points and badges system with MongoDB transactions: Point System: - Create PointTransaction model for transaction history - Award points atomically using MongoDB transactions - Point values: street adoption (+100), task completion (+50), post creation (+10), event participation (+75) - Track balance after each transaction - Support point deduction for reward redemption Badge System: - Create Badge and UserBadge models - Define badge criteria types: street_adoptions, task_completions, post_creations, event_participations, points_earned - Auto-award badges based on user achievements - Badge rarity levels: common, rare, epic, legendary - Track badge progress for users - Prevent duplicate badge awards Gamification Service: - Implement gamificationService.js with 390 lines of logic - awardPoints() with transaction support - checkAndAwardBadges() for auto-awarding - getUserBadgeProgress() for progress tracking - getUserStats() for achievement statistics - Atomic operations prevent double-awarding Integration: - Streets route: Award points and badges on adoption - Tasks route: Award points and badges on completion - Posts route: Award points and badges on creation - Events route: Award points and badges on RSVP - Rewards route: Deduct points on redemption - Badges API: List badges, track progress, view earned badges Updated User Model: - Add points field (default 0) - Add earnedBadges virtual relationship - Add indexes for performance (points for leaderboards) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
390
backend/services/gamificationService.js
Normal file
390
backend/services/gamificationService.js
Normal file
@@ -0,0 +1,390 @@
|
||||
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<Object>} - 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>} - 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,
|
||||
};
|
||||
Reference in New Issue
Block a user