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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user