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>
391 lines
9.5 KiB
JavaScript
391 lines
9.5 KiB
JavaScript
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,
|
|
};
|