Files
adopt-a-street/backend/services/gamificationService.js
William Valentin 3e4c730860 feat: implement comprehensive gamification, analytics, and leaderboard system
This commit adds a complete gamification system with analytics dashboards,
leaderboards, and enhanced badge tracking functionality.

Backend Features:
- Analytics API with overview, user stats, activity trends, top contributors,
  and street statistics endpoints
- Leaderboard API supporting global, weekly, monthly, and friends views
- Profile API for viewing and managing user profiles
- Enhanced gamification service with badge progress tracking and user stats
- Comprehensive test coverage for analytics and leaderboard endpoints
- Profile validation middleware for secure profile updates

Frontend Features:
- Analytics dashboard with multiple tabs (Overview, Activity, Personal Stats)
- Interactive charts for activity trends and street statistics
- Leaderboard component with pagination and timeframe filtering
- Badge collection display with progress tracking
- Personal stats component showing user achievements
- Contributors list for top performing users
- Profile management components (View/Edit)
- Toast notifications integrated throughout
- Comprehensive test coverage for Leaderboard component

Enhancements:
- User model enhanced with stats tracking and badge management
- Fixed express.Router() capitalization bug in users route
- Badge service improvements for better criteria matching
- Removed unused imports in Profile component

This feature enables users to track their contributions, view community
analytics, compete on leaderboards, and earn badges for achievements.

🤖 Generated with OpenCode

Co-Authored-By: AI Assistant <noreply@opencode.ai>
2025-11-03 13:53:48 -08:00

841 lines
24 KiB
JavaScript

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<Object>} - 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<Array>} 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
});
// Enrich with stats and badges
const leaderboard = await Promise.all(result.map(async (user, index) => {
// Get user badges
const userBadges = await UserBadge.findByUser(user._id);
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<Array>} 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 transactions = await couchdbService.find({
selector: {
type: "point_transaction",
createdAt: { $gte: startOfWeek.toISOString() }
}
});
// 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 userBadges = await UserBadge.findByUser(userId);
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<Array>} 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 transactions = await couchdbService.find({
selector: {
type: "point_transaction",
createdAt: { $gte: startOfMonth.toISOString() }
}
});
// 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 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: 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<Array>} 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 = 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<Object>} 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<Object>} 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,
};