Files
adopt-a-street/backend/models/User.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

223 lines
8.1 KiB
JavaScript

const bcrypt = require("bcryptjs");
const couchdbService = require("../services/couchdbService");
const {
ValidationError,
withErrorHandling,
createErrorContext,
} = require("../utils/modelErrors");
const URL_REGEX = /^(https?|ftp):\/\/[^\s\/$.?#].[^\s]*$/i;
class User {
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;
this._rev = data._rev || null;
this.type = "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);
this.adoptedStreets = data.adoptedStreets || [];
this.completedTasks = data.completedTasks || [];
this.posts = data.posts || [];
this.events = data.events || [];
this.earnedBadges = data.earnedBadges || [];
this.stats = data.stats || {
streetsAdopted: 0,
tasksCompleted: 0,
postsCreated: 0,
eventsParticipated: 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 couchdeService.findUserById(query._id); }
else { // Generic query fallback
const docs = await couchdeService.find({
selector: { type: "user", ...query },
limit: 1
});
user = docs[0] || null;
}
return user ? new User(user) : null;
}, errorContext);
}
static async findById(id) {
const errorContext = createErrorContext('User', 'findById', { id });
return await withErrorHandling(async () => {
const user = await couchdeService.findUserById(id);
return user ? new User(user) : null;
}, errorContext);
}
static async findByIdAndUpdate(id, update, options = {}) {
const errorContext = createErrorContext('User', 'findByIdAndUpdate', { id, update, options });
return await withErrorHandling(async () => {
const user = await couchdeService.findUserById(id);
if (!user) return null;
const updatedUser = { ...user, ...update, updatedAt: new Date().toISOString() };
const saved = await couchdeService.update(id, updatedUser);
if (options.new) { return new User(saved); }
return new User(user);
}, errorContext);
}
static async findByIdAndDelete(id) {
const errorContext = createErrorContext('User', 'findByIdAndDelete', { id });
return await withErrorHandling(async () => {
const user = await couchdeService.findUserById(id);
if (!user) return null;
await couchdbService.delete(id);
return user;
}, errorContext);
}
static async find(query = {}) {
const errorContext = createErrorContext('User', 'find', { query });
return await withErrorHandling(async () => {
const selector = { type: "user", ...query };
const users = await couchdbService.find({ selector });
return users.map(u => new User(u));
}, errorContext);
}
static async create(userData) {
const errorContext = createErrorContext('User', 'create', { userData: { ...userData, password: '[REDACTED]' } });
return await withErrorHandling(async () => {
const user = new User(userData);
if (user.password) { const salt = await bcrypt.genSalt(10); user.password = await bcrypt.hash(user.password, salt); }
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);
}
async save() {
const errorContext = createErrorContext('User', 'save', {
id: this._id,
email: this.email,
isNew: !this._id
});
return await withErrorHandling(async () => {
this.updatedAt = new Date().toISOString();
if (!this._id) {
this._id = `user_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
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 {
const updated = await couchdeService.updateDocument(this.toJSON());
this._rev = updated._rev;
return this;
}
}, errorContext);
}
async comparePassword(candidatePassword) {
const errorContext = createErrorContext('User', 'comparePassword', {
id: this._id,
email: this.email
});
return await withErrorHandling(async () => {
return await bcrypt.compare(candidatePassword, this.password);
}, errorContext);
}
toSafeObject() {
const obj = this.toJSON();
delete obj.password;
return obj;
}
toJSON() {
return {
_id: this._id,
_rev: this._rev,
type: this.type,
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,
earnedBadges: this.earnedBadges,
stats: this.stats,
createdAt: this.createdAt,
updatedAt: this.updatedAt,
};
}
}
module.exports = User;