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>
This commit is contained in:
William Valentin
2025-11-03 13:53:48 -08:00
parent ae77e30ffb
commit 3e4c730860
34 changed files with 5533 additions and 190 deletions

View File

@@ -1,25 +1,19 @@
const bcrypt = require("bcryptjs");
const couchdbService = require("../services/couchdbService");
const {
ValidationError,
NotFoundError,
DatabaseError,
DuplicateError,
const {
ValidationError,
withErrorHandling,
createErrorContext
createErrorContext,
} = require("../utils/modelErrors");
const URL_REGEX = /^(https?|ftp):\/\/[^\s\/$.?#].[^\s]*$/i;
class User {
constructor(data) {
// Validate required fields
if (!data.name) {
throw new ValidationError('Name is required', 'name', data.name);
}
if (!data.email) {
throw new ValidationError('Email is required', 'email', data.email);
}
if (!data.password) {
throw new ValidationError('Password is required', 'password', data.password);
constructor(data) { // Validate required fields for new user creation
if (!data._id) { // Only for new users
if (!data.name) { throw new ValidationError("Name is required", "name", data.name); }
if (!data.email) { throw new ValidationError("Email is required", "email", data.email); }
if (!data.password) { throw new ValidationError("Password is required", "password", data.password); }
}
this._id = data._id || null;
@@ -28,39 +22,60 @@ class User {
this.name = data.name;
this.email = data.email;
this.password = data.password;
// --- Profile Information ---
this.avatar = data.avatar || null;
this.cloudinaryPublicId = data.cloudinaryPublicId || null;
this.bio = data.bio || "";
if (this.bio.length > 510) { throw new ValidationError("Bio cannot exceed 500 characters.", "bio", this.bio); }
this.location = data.location || "";
this.website = data.website || "";
if (this.website && !URL_REGEX.test(this.website)) { throw new ValidationError("Invalid website URL.", "website", this.website); }
// --- Social Links ---
this.social = data.social || { twitter: "", github: "", linkedin: "" };
if (this.social.twitter && !URL_REGEX.test(this.social.twitter)) { throw new ValidationError("Invalid Twitter URL.", "social.twitter", this.social.twitter); }
if (this.social.github && !URL_REGEX.test(this.social.github)) { throw new ValidationError("Invalid Github URL.", "social.github", this.social.github); }
if (this.social.linkedin && !URL_REGEX.test(this.social.linkedin)) { throw new ValidationError("Invalid LinkedIn URL.", "social.linkedin", this.social.linkedin); }
// --- Settings & Preferences ---
this.privacySettings = data.privacySettings || { profileVisibility: "public" };
if (!["public", "private"].includes(this.privacySettings.profileVisibility)) { throw new ValidationError("Profile visibility must be 'public' or 'private.", "privacySettings.profileVisibility", this.privacySettings.profileVisibility); }
this.preferences = data.preferences || { emailNotifications: true, pushNotifications: true, theme: "light" };
if (!["light", "dark"].includes(this.preferences.theme)) { throw new ValidationError("Theme must be light' or 'dark'.", "preferences.theme", this.preferences.theme); }
// --- Gamification & App Data ---
this.isPremium = data.isPremium || false;
this.points = Math.max(0, data.points || 0); // Ensure non-negative
this.points = Math.max(0, data.points || 0);
this.adoptedStreets = data.adoptedStreets || [];
this.completedTasks = data.completedTasks || [];
this.posts = data.posts || [];
this.events = data.events || [];
this.profilePicture = data.profilePicture || null;
this.cloudinaryPublicId = data.cloudinaryPublicId || null;
this.earnedBadges = data.earnedBadges || [];
this.stats = data.stats || {
streetsAdopted: 0,
tasksCompleted: 0,
postsCreated: 0,
eventsParticipated: 0,
badgesEarned: 0
badgesEarned: 0,
};
this.createdAt = data.createdAt || new Date().toISOString();
this.updatedAt = data.updatedAt || new Date().toISOString();
}
// ... (static methods remain the same)
// Static methods for MongoDB compatibility
static async findOne(query) {
const errorContext = createErrorContext('User', 'findOne', { query });
return await withErrorHandling(async () => {
let user;
if (query.email) {
user = await couchdbService.findUserByEmail(query.email);
} else if (query._id) {
user = await couchdbService.findUserById(query._id);
} else {
// Generic query fallback
const docs = await couchdbService.find({
if (query.email) { user = await couchdbService.findUserByEmail(query.email); }
else if (query._id) { user = await couchdeService.findUserById(query._id); }
else { // Generic query fallback
const docs = await couchdeService.find({
selector: { type: "user", ...query },
limit: 1
});
@@ -74,7 +89,7 @@ class User {
const errorContext = createErrorContext('User', 'findById', { id });
return await withErrorHandling(async () => {
const user = await couchdbService.findUserById(id);
const user = await couchdeService.findUserById(id);
return user ? new User(user) : null;
}, errorContext);
}
@@ -83,16 +98,14 @@ class User {
const errorContext = createErrorContext('User', 'findByIdAndUpdate', { id, update, options });
return await withErrorHandling(async () => {
const user = await couchdbService.findUserById(id);
const user = await couchdeService.findUserById(id);
if (!user) return null;
const updatedUser = { ...user, ...update, updatedAt: new Date().toISOString() };
const saved = await couchdbService.update(id, updatedUser);
const saved = await couchdeService.update(id, updatedUser);
if (options.new) {
return saved;
}
return user;
if (options.new) { return new User(saved); }
return new User(user);
}, errorContext);
}
@@ -100,7 +113,7 @@ class User {
const errorContext = createErrorContext('User', 'findByIdAndDelete', { id });
return await withErrorHandling(async () => {
const user = await couchdbService.findUserById(id);
const user = await couchdeService.findUserById(id);
if (!user) return null;
await couchdbService.delete(id);
@@ -113,7 +126,8 @@ class User {
return await withErrorHandling(async () => {
const selector = { type: "user", ...query };
return await couchdbService.find({ selector });
const users = await couchdbService.find({ selector });
return users.map(u => new User(u));
}, errorContext);
}
@@ -123,23 +137,15 @@ class User {
return await withErrorHandling(async () => {
const user = new User(userData);
// Hash password if provided
if (user.password) {
const salt = await bcrypt.genSalt(10);
user.password = await bcrypt.hash(user.password, salt);
}
if (user.password) { const salt = await bcrypt.genSalt(10); user.password = await bcrypt.hash(user.password, salt); }
// Generate ID if not provided
if (!user._id) {
user._id = `user_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
}
if (!user._id) { user._id = `user_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; }
const created = await couchdbService.createDocument(user.toJSON());
return new User(created);
}, errorContext);
}
// Instance methods
async save() {
const errorContext = createErrorContext('User', 'save', {
id: this._id,
@@ -148,23 +154,18 @@ class User {
});
return await withErrorHandling(async () => {
this.updatedAt = new Date().toISOString();
if (!this._id) {
// New document
this._id = `user_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
// Hash password if not already hashed
if (this.password && !this.password.startsWith('$2')) {
if (this.password && !this.password.startsWith('$2)')) {
const salt = await bcrypt.genSalt(10);
this.password = await bcrypt.hash(this.password, salt);
}
const created = await couchdbService.createDocument(this.toJSON());
this._rev = created._rev;
return this;
} else {
// Update existing document
this.updatedAt = new Date().toISOString();
const updated = await couchdbService.updateDocument(this.toJSON());
const updated = await couchdeService.updateDocument(this.toJSON());
this._rev = updated._rev;
return this;
}
@@ -182,14 +183,12 @@ class User {
}, errorContext);
}
// Helper method to get user without password
toSafeObject() {
const obj = this.toJSON();
delete obj.password;
return obj;
}
// Convert to CouchDB document format
toJSON() {
return {
_id: this._id,
@@ -198,58 +197,26 @@ class User {
name: this.name,
email: this.email,
password: this.password,
avatar: this.avatar,
cloudinaryPublicId: this.cloudinaryPublicId,
bio: this.bio,
location: this.location,
website: this.website,
social: this.social,
privacySettings: this.privacySettings,
preferences: this.preferences,
isPremium: this.isPremium,
points: this.points,
adoptedStreets: this.adoptedStreets,
completedTasks: this.completedTasks,
posts: this.posts,
events: this.events,
profilePicture: this.profilePicture,
cloudinaryPublicId: this.cloudinaryPublicId,
earnedBadges: this.earnedBadges,
stats: this.stats,
createdAt: this.createdAt,
updatedAt: this.updatedAt
updatedAt: this.updatedAt,
};
}
// Static method for select functionality
static async select(fields) {
const errorContext = createErrorContext('User', 'select', { fields });
return await withErrorHandling(async () => {
const users = await couchdbService.find({
selector: { type: "user" },
fields: fields
});
return users.map(user => new User(user));
}, errorContext);
}
}
// Add select method to instance for chaining
User.prototype.select = function(fields) {
const obj = this.toJSON();
const selected = {};
if (fields.includes('-password')) {
// Exclude password
fields = fields.filter(f => f !== '-password');
fields.forEach(field => {
if (obj[field] !== undefined) {
selected[field] = obj[field];
}
});
} else {
// Include only specified fields
fields.forEach(field => {
if (obj[field] !== undefined) {
selected[field] = obj[field];
}
});
}
return selected;
};
module.exports = User;