diff --git a/GAMIFICATION.md b/GAMIFICATION.md
new file mode 100644
index 0000000..b8b1945
--- /dev/null
+++ b/GAMIFICATION.md
@@ -0,0 +1,268 @@
+# Social Expense Challenges & Gamification System
+
+This document describes the gamification system that makes expense tracking more engaging through challenges, achievements, leaderboards, and streak tracking.
+
+## Overview
+
+The gamification system introduces game mechanics to encourage better financial habits:
+- **Challenges**: Time-limited goals users can create or join
+- **Achievements**: Badges earned for reaching milestones
+- **Leaderboards**: Compete with friends and the community
+- **Streaks**: Track consistent behavior over time
+
+## Features
+
+### 1. Financial Challenges
+
+#### Challenge Types
+- **No Spend**: Track days with zero spending
+- **Category Reduction**: Reduce spending in specific categories
+- **Savings Target**: Save a specific amount
+- **Budget Adherence**: Stay under budget for X days
+- **Streak**: Maintain a behavior streak
+- **Custom**: User-defined challenges
+
+#### Pre-built Challenge Templates
+| Template | Type | Difficulty | Points |
+|----------|------|------------|--------|
+| No Spend Weekend | no_spend | Easy | 50 |
+| No Spend Week | no_spend | Medium | 150 |
+| Coffee Shop Savings | category_reduction | Medium | 100 |
+| Meal Prep Month | category_reduction | Hard | 200 |
+| Entertainment Detox | category_reduction | Medium | 120 |
+| Savings Sprint | savings_target | Medium | 150 |
+| Budget Warrior | budget_adherence | Hard | 250 |
+| Tracking Streak | streak | Easy | 75 |
+
+### 2. Achievement Badges
+
+#### Categories
+- **Tracking**: Expense tracking milestones
+- **Budgeting**: Budget adherence achievements
+- **Savings**: Savings-related accomplishments
+- **Streaks**: Consecutive day achievements
+- **Challenges**: Challenge completion badges
+- **Milestones**: Goal completion achievements
+
+#### Achievement Tiers
+- ๐ฅ **Bronze**: Entry-level achievements
+- ๐ฅ **Silver**: Intermediate achievements
+- ๐ฅ **Gold**: Advanced achievements
+- ๐ **Platinum**: Expert achievements
+- ๐ **Diamond**: Master achievements
+
+#### Sample Achievements
+| Badge | Name | Requirement | Points |
+|-------|------|-------------|--------|
+| ๐ฑ | First Steps | Track first expense | 10 |
+| ๐ | Dedicated Tracker | Track 50 expenses | 25 |
+| ๐ | Tracking Master | Track 500 expenses | 100 |
+| ๐ฐ | Budget Beginner | Stay under budget 7 days | 20 |
+| ๐ | Budget Master | Stay under budget 90 days | 150 |
+| ๐ฅ | Week Warrior | 7-day login streak | 15 |
+| ๐ฏ | Goal Crusher | Complete 5 goals | 100 |
+| ๐ | Challenge Champion | Complete 10 challenges | 150 |
+
+### 3. Leaderboard System
+
+#### Leaderboard Types
+- **All Time**: Cumulative points ranking
+- **Monthly**: Monthly points reset
+- **Weekly**: Weekly points reset
+- **Friends**: Users in shared workspaces
+
+#### Privacy Controls
+Users can configure:
+- `showOnLeaderboard`: Appear on public leaderboards
+- `showAchievements`: Display earned achievements publicly
+- `showChallenges`: Show challenge participation
+- `showStats`: Share detailed statistics
+
+### 4. Streak Tracking
+
+#### Tracked Streaks
+- **Login Streak**: Consecutive days logged in
+- **Expense Tracking**: Days tracking expenses
+- **Budget Adherence**: Days staying under budget
+- **No Spend**: Consecutive no-spend days
+- **Savings**: Days with positive savings
+
+### 5. Leveling System
+
+#### Level Progression
+- Each level requires progressively more XP
+- Formula: `baseXP * level * 1.5`
+- Points earned = Experience gained
+
+#### Rank Titles
+| Level Range | Rank |
+|-------------|------|
+| 1-4 | Novice |
+| 5-9 | Apprentice |
+| 10-19 | Adept |
+| 20-29 | Expert |
+| 30-39 | Master |
+| 40-49 | Grandmaster |
+| 50+ | Legend |
+
+## API Endpoints
+
+### Profile & Stats
+```
+GET /api/gamification/profile - Get user's gamification profile
+GET /api/gamification/stats - Get detailed stats
+PATCH /api/gamification/privacy - Update privacy settings
+```
+
+### Achievements
+```
+GET /api/gamification/achievements - Get all achievements with progress
+GET /api/gamification/achievements/recent - Get recently earned achievements
+```
+
+### Leaderboard
+```
+GET /api/gamification/leaderboard - Get global leaderboard
+GET /api/gamification/leaderboard/friends - Get friends leaderboard
+```
+
+### Challenges
+```
+POST /api/gamification/challenges - Create challenge
+GET /api/gamification/challenges - Get user's challenges
+GET /api/gamification/challenges/discover - Discover public challenges
+GET /api/gamification/challenges/templates - Get challenge templates
+GET /api/gamification/challenges/:id - Get challenge details
+POST /api/gamification/challenges/:id/join - Join challenge
+POST /api/gamification/challenges/:id/leave - Leave challenge
+PATCH /api/gamification/challenges/:id/progress - Update progress
+PUT /api/gamification/challenges/:id - Update challenge (creator)
+DELETE /api/gamification/challenges/:id - Delete/cancel challenge
+POST /api/gamification/challenges/:id/invite - Invite users
+```
+
+### Initialize
+```
+POST /api/gamification/init - Initialize default achievements
+```
+
+## Data Models
+
+### Challenge Schema
+```javascript
+{
+ title: String,
+ description: String,
+ type: 'no_spend' | 'category_reduction' | 'savings_target' | 'streak' | 'budget_adherence' | 'custom',
+ category: String,
+ targetValue: Number,
+ targetUnit: 'days' | 'amount' | 'percentage' | 'count',
+ startDate: Date,
+ endDate: Date,
+ creator: ObjectId,
+ isPublic: Boolean,
+ difficulty: 'easy' | 'medium' | 'hard' | 'extreme',
+ rewardPoints: Number,
+ participants: [{
+ user: ObjectId,
+ progress: Number,
+ currentStreak: Number,
+ status: 'active' | 'completed' | 'failed' | 'withdrawn'
+ }]
+}
+```
+
+### UserGamification Schema
+```javascript
+{
+ user: ObjectId,
+ totalPoints: Number,
+ level: Number,
+ experience: Number,
+ rank: 'novice' | 'apprentice' | 'adept' | 'expert' | 'master' | 'grandmaster' | 'legend',
+ earnedAchievements: [{ achievement: ObjectId, earnedAt: Date }],
+ achievementProgress: [{ achievementCode: String, currentValue: Number, targetValue: Number }],
+ streaks: [{ type: String, currentStreak: Number, longestStreak: Number }],
+ privacySettings: {
+ showOnLeaderboard: Boolean,
+ showAchievements: Boolean,
+ showChallenges: Boolean,
+ showStats: Boolean
+ },
+ stats: {
+ totalExpensesTracked: Number,
+ totalGoalsCompleted: Number,
+ loginStreak: Number,
+ // ... other stats
+ }
+}
+```
+
+## Integration Points
+
+### Automatic Tracking
+The gamification service integrates with existing features:
+
+1. **Expense Tracking**: Call `gamificationService.trackExpense(userId, expense)` when an expense is created
+2. **Login**: Call `gamificationService.trackLogin(userId)` on successful authentication
+3. **Goal Completion**: Call `gamificationService.trackGoalCompletion(userId)` when a goal is achieved
+4. **Receipt Upload**: Call `gamificationService.trackReceiptUpload(userId)` on receipt upload
+5. **Analytics View**: Call `gamificationService.trackAnalyticsView(userId)` when viewing dashboard
+
+### Example Integration
+```javascript
+// In expense route
+router.post('/', auth, async (req, res) => {
+ // ... create expense
+
+ // Track for gamification
+ const gamificationService = require('../services/gamificationService');
+ await gamificationService.trackExpense(req.user._id, expense);
+});
+```
+
+## Real-time Updates
+
+The system emits Socket.IO events:
+- `points_earned`: When user earns points
+- `achievement_unlocked`: When new achievement is earned
+- `challenge_completed`: When challenge is completed
+- `challenge_invitation`: When invited to a challenge
+
+## Frontend Usage
+
+```javascript
+// Initialize
+const gamification = new GamificationManager();
+await gamification.init();
+
+// Create a challenge
+await gamification.createChallenge({
+ title: 'My Challenge',
+ description: 'Challenge description',
+ type: 'no_spend',
+ targetValue: 7,
+ startDate: new Date().toISOString(),
+ endDate: new Date(Date.now() + 7*24*60*60*1000).toISOString()
+});
+
+// Join a challenge
+await gamification.joinChallenge(challengeId);
+
+// Get leaderboard
+await gamification.loadLeaderboard('weekly');
+```
+
+## Best Practices
+
+1. **Initialize Achievements**: Run `POST /api/gamification/init` once to seed default achievements
+2. **Periodic Resets**: Use cron jobs to reset weekly/monthly points
+3. **Challenge Updates**: Regularly update challenge statuses based on dates
+4. **Progress Calculation**: Recalculate challenge progress when expenses change
+
+## Security Considerations
+
+- All endpoints require authentication
+- Users can only modify their own challenges or challenges they created
+- Privacy settings control public visibility
+- Rate limiting applied to prevent abuse
diff --git a/middleware/gamificationValidator.js b/middleware/gamificationValidator.js
index d2b6ceb..fd98ef5 100644
--- a/middleware/gamificationValidator.js
+++ b/middleware/gamificationValidator.js
@@ -1,123 +1,185 @@
const Joi = require('joi');
-// Schema for creating a challenge
-const createChallengeSchema = Joi.object({
- title: Joi.string().trim().max(100).required()
- .messages({
- 'string.empty': 'Challenge title is required',
- 'string.max': 'Title must be less than 100 characters'
- }),
- description: Joi.string().trim().max(500).required()
- .messages({
- 'string.empty': 'Description is required',
- 'string.max': 'Description must be less than 500 characters'
- }),
- type: Joi.string().valid(
- 'no_spend', 'savings_target', 'budget_under',
- 'category_limit', 'streak', 'reduction', 'custom'
- ).required()
- .messages({
- 'any.only': 'Invalid challenge type'
- }),
- scope: Joi.string().valid('personal', 'friends', 'public', 'workspace').default('personal'),
- config: Joi.object({
- targetAmount: Joi.number().min(0).optional(),
- targetPercentage: Joi.number().min(0).max(100).optional(),
- category: Joi.string().valid(
- 'food', 'transport', 'entertainment', 'utilities',
- 'healthcare', 'shopping', 'other', 'all'
- ).optional(),
- comparisonPeriod: Joi.number().min(1).max(365).default(30),
- streakDays: Joi.number().min(1).max(365).optional()
- }).optional(),
- startDate: Joi.date().required()
- .messages({
- 'date.base': 'Valid start date is required'
- }),
- endDate: Joi.date().greater(Joi.ref('startDate')).required()
- .messages({
- 'date.greater': 'End date must be after start date',
- 'date.base': 'Valid end date is required'
- }),
- rewards: Joi.object({
- points: Joi.number().min(0).max(10000).default(100)
- }).optional(),
- icon: Joi.string().max(10).default('๐ฏ'),
- difficulty: Joi.string().valid('easy', 'medium', 'hard', 'extreme').default('medium'),
- tags: Joi.array().items(Joi.string().trim().max(30)).max(10).optional(),
- showOnLeaderboard: Joi.boolean().default(true)
+const challengeSchema = Joi.object({
+ title: Joi.string().trim().min(3).max(100).required()
+ .messages({
+ 'string.min': 'Title must be at least 3 characters',
+ 'string.max': 'Title cannot exceed 100 characters'
+ }),
+ description: Joi.string().trim().min(10).max(500).required()
+ .messages({
+ 'string.min': 'Description must be at least 10 characters',
+ 'string.max': 'Description cannot exceed 500 characters'
+ }),
+ type: Joi.string().valid(
+ 'no_spend',
+ 'category_reduction',
+ 'savings_target',
+ 'streak',
+ 'budget_adherence',
+ 'custom'
+ ).required()
+ .messages({
+ 'any.only': 'Invalid challenge type'
+ }),
+ category: Joi.string().valid(
+ 'food', 'transport', 'entertainment', 'utilities',
+ 'healthcare', 'shopping', 'coffee', 'dining', 'other', 'all'
+ ).default('all'),
+ targetValue: Joi.number().min(1).required()
+ .messages({
+ 'number.min': 'Target value must be at least 1'
+ }),
+ targetUnit: Joi.string().valid('days', 'amount', 'percentage', 'count').default('days'),
+ startDate: Joi.date().iso().min('now').required()
+ .messages({
+ 'date.min': 'Start date must be in the future'
+ }),
+ endDate: Joi.date().iso().greater(Joi.ref('startDate')).required()
+ .messages({
+ 'date.greater': 'End date must be after start date'
+ }),
+ isPublic: Joi.boolean().default(true),
+ difficulty: Joi.string().valid('easy', 'medium', 'hard', 'extreme').default('medium'),
+ rewardPoints: Joi.number().min(10).max(1000).default(100),
+ maxParticipants: Joi.number().min(0).max(1000).default(0),
+ rules: Joi.string().trim().max(1000).optional(),
+ icon: Joi.string().max(10).default('๐ฏ')
});
-// Schema for updating challenge progress
-const updateProgressSchema = Joi.object({
- increment: Joi.number().optional(),
- set: Joi.number().optional(),
- dailyValue: Joi.number().optional(),
- achieved: Joi.boolean().optional()
-}).or('increment', 'set', 'achieved')
- .messages({
- 'object.missing': 'At least one of increment, set, or achieved must be provided'
- });
+const challengeUpdateSchema = Joi.object({
+ title: Joi.string().trim().min(3).max(100),
+ description: Joi.string().trim().min(10).max(500),
+ isPublic: Joi.boolean(),
+ rules: Joi.string().trim().max(1000),
+ icon: Joi.string().max(10)
+}).min(1);
-// Middleware for validating challenge create request
-const validateChallengeCreate = (req, res, next) => {
- const { error, value } = createChallengeSchema.validate(req.body, {
- abortEarly: false,
- stripUnknown: true
- });
+const progressUpdateSchema = Joi.object({
+ progress: Joi.number().min(0).max(100),
+ currentStreak: Joi.number().min(0),
+ savedAmount: Joi.number().min(0),
+ completedDay: Joi.boolean(),
+ dayValue: Joi.number()
+});
- if (error) {
- const errors = error.details.map(detail => ({
- field: detail.path.join('.'),
- message: detail.message
- }));
- return res.status(400).json({
- error: 'Validation failed',
- details: errors
- });
- }
+const privacySettingsSchema = Joi.object({
+ showOnLeaderboard: Joi.boolean(),
+ showAchievements: Joi.boolean(),
+ showChallenges: Joi.boolean(),
+ showStats: Joi.boolean()
+}).min(1);
- req.validatedBody = value;
- next();
-};
+const leaderboardQuerySchema = Joi.object({
+ type: Joi.string().valid('all_time', 'weekly', 'monthly').default('all_time'),
+ limit: Joi.number().min(1).max(100).default(50)
+});
-// Middleware for validating progress update request
-const validateProgressUpdate = (req, res, next) => {
- const { error, value } = updateProgressSchema.validate(req.body, {
- abortEarly: false,
- stripUnknown: true
- });
+const challengeFiltersSchema = Joi.object({
+ type: Joi.string().valid(
+ 'no_spend', 'category_reduction', 'savings_target',
+ 'streak', 'budget_adherence', 'custom'
+ ),
+ difficulty: Joi.string().valid('easy', 'medium', 'hard', 'extreme'),
+ status: Joi.string().valid('upcoming', 'active', 'completed'),
+ limit: Joi.number().min(1).max(50).default(20)
+});
- if (error) {
- const errors = error.details.map(detail => ({
- field: detail.path.join('.'),
- message: detail.message
- }));
- return res.status(400).json({
- error: 'Validation failed',
- details: errors
- });
- }
+const validateChallenge = (req, res, next) => {
+ const { error, value } = challengeSchema.validate(req.body, { abortEarly: false });
+
+ if (error) {
+ const errors = error.details.map(detail => ({
+ field: detail.path.join('.'),
+ message: detail.message
+ }));
+ return res.status(400).json({ error: 'Validation failed', details: errors });
+ }
+
+ req.body = value;
+ next();
+};
- req.validatedBody = value;
- next();
+const validateChallengeUpdate = (req, res, next) => {
+ const { error, value } = challengeUpdateSchema.validate(req.body, { abortEarly: false });
+
+ if (error) {
+ const errors = error.details.map(detail => ({
+ field: detail.path.join('.'),
+ message: detail.message
+ }));
+ return res.status(400).json({ error: 'Validation failed', details: errors });
+ }
+
+ req.body = value;
+ next();
};
-// Validate MongoDB ObjectId
-const validateObjectId = (req, res, next) => {
- const { id } = req.params;
+const validateProgressUpdate = (req, res, next) => {
+ const { error, value } = progressUpdateSchema.validate(req.body, { abortEarly: false });
+
+ if (error) {
+ const errors = error.details.map(detail => ({
+ field: detail.path.join('.'),
+ message: detail.message
+ }));
+ return res.status(400).json({ error: 'Validation failed', details: errors });
+ }
+
+ req.body = value;
+ next();
+};
+
+const validatePrivacySettings = (req, res, next) => {
+ const { error, value } = privacySettingsSchema.validate(req.body, { abortEarly: false });
+
+ if (error) {
+ const errors = error.details.map(detail => ({
+ field: detail.path.join('.'),
+ message: detail.message
+ }));
+ return res.status(400).json({ error: 'Validation failed', details: errors });
+ }
+
+ req.body = value;
+ next();
+};
- if (!id.match(/^[0-9a-fA-F]{24}$/)) {
- return res.status(400).json({ error: 'Invalid ID format' });
- }
+const validateLeaderboardQuery = (req, res, next) => {
+ const { error, value } = leaderboardQuerySchema.validate(req.query, { abortEarly: false });
+
+ if (error) {
+ const errors = error.details.map(detail => ({
+ field: detail.path.join('.'),
+ message: detail.message
+ }));
+ return res.status(400).json({ error: 'Validation failed', details: errors });
+ }
+
+ req.query = value;
+ next();
+};
- next();
+const validateChallengeFilters = (req, res, next) => {
+ const { error, value } = challengeFiltersSchema.validate(req.query, { abortEarly: false });
+
+ if (error) {
+ const errors = error.details.map(detail => ({
+ field: detail.path.join('.'),
+ message: detail.message
+ }));
+ return res.status(400).json({ error: 'Validation failed', details: errors });
+ }
+
+ req.query = value;
+ next();
};
module.exports = {
- validateChallengeCreate,
- validateProgressUpdate,
- validateObjectId,
- createChallengeSchema,
- updateProgressSchema
+ validateChallenge,
+ validateChallengeUpdate,
+ validateProgressUpdate,
+ validatePrivacySettings,
+ validateLeaderboardQuery,
+ validateChallengeFilters
};
diff --git a/models/Achievement.js b/models/Achievement.js
index da10e86..5c8369e 100644
--- a/models/Achievement.js
+++ b/models/Achievement.js
@@ -1,111 +1,83 @@
const mongoose = require('mongoose');
-// Achievement Definition Schema
const achievementSchema = new mongoose.Schema({
- // Unique identifier code
code: {
type: String,
required: true,
unique: true,
- uppercase: true,
trim: true
},
- // Display name
name: {
type: String,
required: true,
trim: true,
maxlength: 100
},
- // Description
description: {
type: String,
required: true,
trim: true,
maxlength: 300
},
- // Category of achievement
+ icon: {
+ type: String,
+ required: true,
+ default: '๐'
+ },
category: {
type: String,
required: true,
- enum: [
- 'budgeting', // Budget-related achievements
- 'savings', // Savings achievements
- 'streaks', // Streak-based achievements
- 'goals', // Goal achievements
- 'analytics', // Dashboard/analytics usage
- 'social', // Social/sharing achievements
- 'challenges', // Challenge completions
- 'milestones', // General milestones
- 'special' // Special/limited achievements
- ]
+ enum: ['savings', 'budgeting', 'tracking', 'social', 'challenges', 'streaks', 'milestones', 'special']
+ },
+ tier: {
+ type: String,
+ enum: ['bronze', 'silver', 'gold', 'platinum', 'diamond'],
+ default: 'bronze'
+ },
+ points: {
+ type: Number,
+ default: 10,
+ min: 0
},
- // Achievement criteria
- criteria: {
+ requirement: {
type: {
type: String,
required: true,
enum: [
- 'budget_streak', // Stay under budget for X months
- 'savings_streak', // Save consistently for X days
- 'dashboard_streak', // Check dashboard for X days
- 'goals_completed', // Complete X goals
- 'challenges_won', // Win X challenges
- 'total_saved', // Save total of X amount
- 'expense_logged', // Log X expenses
- 'category_mastered', // Keep category under budget X times
- 'first_action', // First time doing something
- 'social_action', // Social activity (share, invite, etc.)
- 'custom' // Custom criteria
+ 'budget_streak', // Stay under budget for X days/months
+ 'savings_amount', // Save total X amount
+ 'expense_tracking', // Track X expenses
+ 'goal_completion', // Complete X goals
+ 'challenge_wins', // Win X challenges
+ 'login_streak', // Login X days in a row
+ 'category_master', // Track X expenses in one category
+ 'analytics_usage', // View analytics X times
+ 'first_action', // First time doing something
+ 'social_engagement', // Invite/compete with friends
+ 'no_spend_days', // Have X no-spend days
+ 'receipt_uploads', // Upload X receipts
+ 'custom'
]
},
- // Target value for the criteria
- targetValue: {
+ value: {
type: Number,
- required: true,
- min: 1
+ required: true
},
- // Category if specific to a category
- category: String,
- // Time period in days (if applicable)
- periodDays: Number
- },
- // Badge icon/emoji
- badge: {
- type: String,
- required: true
+ category: String, // For category-specific achievements
+ timeframe: String // 'daily', 'weekly', 'monthly', 'yearly', 'all_time'
},
- // Badge color
- color: {
- type: String,
- default: '#FFD700'
+ isSecret: {
+ type: Boolean,
+ default: false
},
- // Points awarded
- points: {
- type: Number,
- default: 50,
- min: 0
+ isActive: {
+ type: Boolean,
+ default: true
},
- // Rarity
rarity: {
type: String,
enum: ['common', 'uncommon', 'rare', 'epic', 'legendary'],
default: 'common'
- },
- // Order for display
- displayOrder: {
- type: Number,
- default: 0
- },
- // Is achievement active
- isActive: {
- type: Boolean,
- default: true
- },
- // Is hidden until earned
- isSecret: {
- type: Boolean,
- default: false
}
}, {
timestamps: true
@@ -114,6 +86,6 @@ const achievementSchema = new mongoose.Schema({
// Indexes
achievementSchema.index({ code: 1 });
achievementSchema.index({ category: 1, isActive: 1 });
-achievementSchema.index({ rarity: 1 });
+achievementSchema.index({ tier: 1 });
module.exports = mongoose.model('Achievement', achievementSchema);
diff --git a/models/Challenge.js b/models/Challenge.js
index 81f3800..412a041 100644
--- a/models/Challenge.js
+++ b/models/Challenge.js
@@ -1,14 +1,45 @@
const mongoose = require('mongoose');
-// Challenge Schema - For financial challenges users can participate in
-const challengeSchema = new mongoose.Schema({
- // Challenge creator (null for system challenges)
- creator: {
+const participantSchema = new mongoose.Schema({
+ user: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
- default: null
+ required: true
+ },
+ joinedAt: {
+ type: Date,
+ default: Date.now
+ },
+ progress: {
+ type: Number,
+ default: 0,
+ min: 0
+ },
+ currentStreak: {
+ type: Number,
+ default: 0
},
- // Challenge details
+ bestStreak: {
+ type: Number,
+ default: 0
+ },
+ completedDays: [{
+ date: Date,
+ value: Number
+ }],
+ status: {
+ type: String,
+ enum: ['active', 'completed', 'failed', 'withdrawn'],
+ default: 'active'
+ },
+ completedAt: Date,
+ savedAmount: {
+ type: Number,
+ default: 0
+ }
+}, { _id: false });
+
+const challengeSchema = new mongoose.Schema({
title: {
type: String,
required: true,
@@ -21,56 +52,33 @@ const challengeSchema = new mongoose.Schema({
trim: true,
maxlength: 500
},
- // Challenge type
type: {
type: String,
required: true,
enum: [
- 'no_spend', // No spending for X days
- 'savings_target', // Save X amount
- 'budget_under', // Stay under budget
- 'category_limit', // Limit spending in category
- 'streak', // Maintain streak for X days
- 'reduction', // Reduce spending by X%
- 'custom' // Custom challenge
+ 'no_spend', // No spending days challenge
+ 'category_reduction', // Reduce spending in a category
+ 'savings_target', // Save a specific amount
+ 'streak', // Maintain a behavior streak
+ 'budget_adherence', // Stay under budget
+ 'custom' // User-defined challenge
]
},
- // Challenge scope
- scope: {
+ category: {
+ type: String,
+ enum: ['food', 'transport', 'entertainment', 'utilities', 'healthcare', 'shopping', 'coffee', 'dining', 'other', 'all'],
+ default: 'all'
+ },
+ targetValue: {
+ type: Number,
+ required: true,
+ min: 0
+ },
+ targetUnit: {
type: String,
- enum: ['personal', 'friends', 'public', 'workspace'],
- default: 'personal'
- },
- // Challenge parameters
- config: {
- // Target amount (for savings, limits)
- targetAmount: {
- type: Number,
- min: 0
- },
- // Target percentage (for reduction challenges)
- targetPercentage: {
- type: Number,
- min: 0,
- max: 100
- },
- // Category to track (for category-specific challenges)
- category: {
- type: String,
- enum: ['food', 'transport', 'entertainment', 'utilities', 'healthcare', 'shopping', 'other', 'all']
- },
- // Comparison period for reduction (in days)
- comparisonPeriod: {
- type: Number,
- default: 30
- },
- // Streak requirement
- streakDays: {
- type: Number,
- min: 1
- }
- },
- // Challenge timing
+ enum: ['days', 'amount', 'percentage', 'count'],
+ default: 'days'
+ },
startDate: {
type: Date,
required: true
@@ -79,80 +87,85 @@ const challengeSchema = new mongoose.Schema({
type: Date,
required: true
},
- // Challenge status
- status: {
- type: String,
- enum: ['upcoming', 'active', 'completed', 'cancelled'],
- default: 'upcoming'
+ creator: {
+ type: mongoose.Schema.Types.ObjectId,
+ ref: 'User',
+ required: true
+ },
+ isPublic: {
+ type: Boolean,
+ default: true
},
- // System challenge flag
isSystemChallenge: {
type: Boolean,
default: false
},
- // Challenge rewards
- rewards: {
- points: {
- type: Number,
- default: 100
- },
- badge: {
- type: String
- },
- achievements: [{
- type: mongoose.Schema.Types.ObjectId,
- ref: 'Achievement'
- }]
- },
- // Challenge icon/emoji
- icon: {
- type: String,
- default: '๐ฏ'
- },
- // Difficulty
difficulty: {
type: String,
enum: ['easy', 'medium', 'hard', 'extreme'],
default: 'medium'
},
- // Tags
- tags: [{
+ rewardPoints: {
+ type: Number,
+ default: 100
+ },
+ rewardBadge: {
type: String,
- trim: true,
- maxlength: 30
+ default: null
+ },
+ participants: [participantSchema],
+ maxParticipants: {
+ type: Number,
+ default: 0 // 0 means unlimited
+ },
+ invitedUsers: [{
+ type: mongoose.Schema.Types.ObjectId,
+ ref: 'User'
}],
- // Privacy for leaderboard
- showOnLeaderboard: {
- type: Boolean,
- default: true
+ rules: {
+ type: String,
+ maxlength: 1000
+ },
+ icon: {
+ type: String,
+ default: '๐ฏ'
+ },
+ status: {
+ type: String,
+ enum: ['upcoming', 'active', 'completed', 'cancelled'],
+ default: 'upcoming'
}
}, {
timestamps: true
});
// Indexes
-challengeSchema.index({ status: 1, startDate: 1 });
challengeSchema.index({ creator: 1, status: 1 });
-challengeSchema.index({ scope: 1, status: 1 });
-challengeSchema.index({ type: 1, status: 1 });
+challengeSchema.index({ isPublic: 1, status: 1, startDate: 1 });
+challengeSchema.index({ 'participants.user': 1, status: 1 });
+challengeSchema.index({ endDate: 1, status: 1 });
-// Virtual for duration in days
-challengeSchema.virtual('durationDays').get(function() {
- return Math.ceil((this.endDate - this.startDate) / (1000 * 60 * 60 * 24));
+// Virtual for participant count
+challengeSchema.virtual('participantCount').get(function() {
+ return this.participants.length;
});
-// Virtual for days remaining
-challengeSchema.virtual('daysRemaining').get(function() {
- if (this.status !== 'active') return 0;
- const now = new Date();
- if (now > this.endDate) return 0;
- return Math.ceil((this.endDate - now) / (1000 * 60 * 60 * 24));
-});
+// Check if user is participant
+challengeSchema.methods.isParticipant = function(userId) {
+ return this.participants.some(p => p.user.toString() === userId.toString());
+};
+
+// Get participant data
+challengeSchema.methods.getParticipant = function(userId) {
+ return this.participants.find(p => p.user.toString() === userId.toString());
+};
-// Check if challenge is active
-challengeSchema.methods.isActive = function() {
+// Calculate days remaining
+challengeSchema.methods.getDaysRemaining = function() {
const now = new Date();
- return now >= this.startDate && now <= this.endDate && this.status === 'active';
+ const end = new Date(this.endDate);
+ const diffTime = end - now;
+ return Math.max(0, Math.ceil(diffTime / (1000 * 60 * 60 * 24)));
};
module.exports = mongoose.model('Challenge', challengeSchema);
diff --git a/models/UserGamification.js b/models/UserGamification.js
index ce779b1..c4bab0d 100644
--- a/models/UserGamification.js
+++ b/models/UserGamification.js
@@ -1,6 +1,61 @@
const mongoose = require('mongoose');
-// User Gamification Profile Schema
+const earnedAchievementSchema = new mongoose.Schema({
+ achievement: {
+ type: mongoose.Schema.Types.ObjectId,
+ ref: 'Achievement',
+ required: true
+ },
+ earnedAt: {
+ type: Date,
+ default: Date.now
+ },
+ progress: {
+ type: Number,
+ default: 100
+ }
+}, { _id: false });
+
+const streakSchema = new mongoose.Schema({
+ type: {
+ type: String,
+ required: true,
+ enum: ['login', 'expense_tracking', 'budget_adherence', 'no_spend', 'savings']
+ },
+ currentStreak: {
+ type: Number,
+ default: 0
+ },
+ longestStreak: {
+ type: Number,
+ default: 0
+ },
+ lastUpdated: {
+ type: Date,
+ default: Date.now
+ },
+ startDate: Date
+}, { _id: false });
+
+const achievementProgressSchema = new mongoose.Schema({
+ achievementCode: {
+ type: String,
+ required: true
+ },
+ currentValue: {
+ type: Number,
+ default: 0
+ },
+ targetValue: {
+ type: Number,
+ required: true
+ },
+ lastUpdated: {
+ type: Date,
+ default: Date.now
+ }
+}, { _id: false });
+
const userGamificationSchema = new mongoose.Schema({
user: {
type: mongoose.Schema.Types.ObjectId,
@@ -8,204 +63,205 @@ const userGamificationSchema = new mongoose.Schema({
required: true,
unique: true
},
- // Points system
- points: {
- total: {
- type: Number,
- default: 0
+ totalPoints: {
+ type: Number,
+ default: 0,
+ min: 0
+ },
+ level: {
+ type: Number,
+ default: 1,
+ min: 1
+ },
+ experience: {
+ type: Number,
+ default: 0,
+ min: 0
+ },
+ experienceToNextLevel: {
+ type: Number,
+ default: 100
+ },
+ earnedAchievements: [earnedAchievementSchema],
+ achievementProgress: [achievementProgressSchema],
+ streaks: [streakSchema],
+ challengesCompleted: {
+ type: Number,
+ default: 0
+ },
+ challengesJoined: {
+ type: Number,
+ default: 0
+ },
+ totalSavedFromChallenges: {
+ type: Number,
+ default: 0
+ },
+ rank: {
+ type: String,
+ enum: ['novice', 'apprentice', 'adept', 'expert', 'master', 'grandmaster', 'legend'],
+ default: 'novice'
+ },
+ weeklyPoints: {
+ type: Number,
+ default: 0
+ },
+ monthlyPoints: {
+ type: Number,
+ default: 0
+ },
+ lastWeeklyReset: {
+ type: Date,
+ default: Date.now
+ },
+ lastMonthlyReset: {
+ type: Date,
+ default: Date.now
+ },
+ privacySettings: {
+ showOnLeaderboard: {
+ type: Boolean,
+ default: true
},
- currentMonth: {
- type: Number,
- default: 0
+ showAchievements: {
+ type: Boolean,
+ default: true
+ },
+ showChallenges: {
+ type: Boolean,
+ default: true
},
- lastMonthReset: {
- type: Date,
- default: Date.now
+ showStats: {
+ type: Boolean,
+ default: false
}
},
- // Level system
- level: {
- current: {
+ stats: {
+ totalNoSpendDays: {
type: Number,
- default: 1
+ default: 0
},
- experience: {
+ totalExpensesTracked: {
type: Number,
default: 0
},
- experienceToNext: {
+ totalReceiptsUploaded: {
type: Number,
- default: 100
- }
- },
- // Streaks
- streaks: {
- // Budget streak (days under budget)
- budget: {
- current: { type: Number, default: 0 },
- longest: { type: Number, default: 0 },
- lastUpdated: Date
- },
- // Savings streak (days with savings)
- savings: {
- current: { type: Number, default: 0 },
- longest: { type: Number, default: 0 },
- lastUpdated: Date
- },
- // Dashboard login streak
- login: {
- current: { type: Number, default: 0 },
- longest: { type: Number, default: 0 },
- lastUpdated: Date
+ default: 0
},
- // Expense logging streak
- logging: {
- current: { type: Number, default: 0 },
- longest: { type: Number, default: 0 },
- lastUpdated: Date
- }
- },
- // Statistics
- stats: {
- challengesJoined: { type: Number, default: 0 },
- challengesCompleted: { type: Number, default: 0 },
- challengesWon: { type: Number, default: 0 },
- achievementsEarned: { type: Number, default: 0 },
- goalsCompleted: { type: Number, default: 0 },
- totalSaved: { type: Number, default: 0 },
- monthsUnderBudget: { type: Number, default: 0 }
- },
- // Leaderboard settings
- leaderboard: {
- showOnPublic: {
- type: Boolean,
- default: true
+ totalGoalsCompleted: {
+ type: Number,
+ default: 0
},
- showProgress: {
- type: Boolean,
- default: true
+ analyticsViews: {
+ type: Number,
+ default: 0
},
- displayName: {
- type: String,
- trim: true,
- maxlength: 50
+ lastLoginDate: Date,
+ loginStreak: {
+ type: Number,
+ default: 0
}
- },
- // Badges earned (achievement IDs for quick display)
- badges: [{
- achievement: {
- type: mongoose.Schema.Types.ObjectId,
- ref: 'Achievement'
- },
- earnedAt: Date
- }],
- // Featured badges (up to 5 to show on profile)
- featuredBadges: [{
- type: mongoose.Schema.Types.ObjectId,
- ref: 'Achievement'
- }],
- // Last activity
- lastActivityAt: {
- type: Date,
- default: Date.now
}
}, {
timestamps: true
});
-// Index
+// Indexes
userGamificationSchema.index({ user: 1 });
-userGamificationSchema.index({ 'points.total': -1 });
-userGamificationSchema.index({ 'points.currentMonth': -1 });
-userGamificationSchema.index({ 'level.current': -1 });
+userGamificationSchema.index({ totalPoints: -1 });
+userGamificationSchema.index({ weeklyPoints: -1 });
+userGamificationSchema.index({ monthlyPoints: -1 });
+userGamificationSchema.index({ level: -1 });
+userGamificationSchema.index({ 'privacySettings.showOnLeaderboard': 1, totalPoints: -1 });
// Calculate level from experience
userGamificationSchema.methods.calculateLevel = function() {
- // Level formula: each level requires (level * 100) XP
- let totalXpNeeded = 0;
+ // Level formula: each level requires progressively more XP
+ // Level 1: 0 XP, Level 2: 100 XP, Level 3: 250 XP, etc.
let level = 1;
+ let totalXpNeeded = 0;
+ const baseXp = 100;
- while (totalXpNeeded + (level * 100) <= this.level.experience) {
- totalXpNeeded += level * 100;
+ while (this.experience >= totalXpNeeded + (baseXp * level * 1.5)) {
+ totalXpNeeded += Math.floor(baseXp * level * 1.5);
level++;
}
- this.level.current = level;
- this.level.experienceToNext = (level * 100) - (this.level.experience - totalXpNeeded);
+ this.level = level;
+ this.experienceToNextLevel = Math.floor(baseXp * level * 1.5) - (this.experience - totalXpNeeded);
+
+ // Update rank based on level
+ if (level >= 50) this.rank = 'legend';
+ else if (level >= 40) this.rank = 'grandmaster';
+ else if (level >= 30) this.rank = 'master';
+ else if (level >= 20) this.rank = 'expert';
+ else if (level >= 10) this.rank = 'adept';
+ else if (level >= 5) this.rank = 'apprentice';
+ else this.rank = 'novice';
return this.level;
};
-// Add points
-userGamificationSchema.methods.addPoints = function(points, reason) {
- this.points.total += points;
- this.points.currentMonth += points;
- this.level.experience += points;
+// Add points and experience
+userGamificationSchema.methods.addPoints = function(points) {
+ this.totalPoints += points;
+ this.weeklyPoints += points;
+ this.monthlyPoints += points;
+ this.experience += points;
this.calculateLevel();
};
+// Check if user has achievement
+userGamificationSchema.methods.hasAchievement = function(achievementId) {
+ return this.earnedAchievements.some(
+ ea => ea.achievement.toString() === achievementId.toString()
+ );
+};
+
+// Get achievement progress
+userGamificationSchema.methods.getProgress = function(achievementCode) {
+ return this.achievementProgress.find(ap => ap.achievementCode === achievementCode);
+};
+
// Update streak
-userGamificationSchema.methods.updateStreak = function(streakType, achieved) {
- if (!this.streaks[streakType]) return;
+userGamificationSchema.methods.updateStreak = function(type, increment = true) {
+ let streak = this.streaks.find(s => s.type === type);
- const streak = this.streaks[streakType];
- const today = new Date();
- today.setHours(0, 0, 0, 0);
-
- const lastUpdate = streak.lastUpdated ? new Date(streak.lastUpdated) : null;
- if (lastUpdate) {
- lastUpdate.setHours(0, 0, 0, 0);
+ if (!streak) {
+ streak = {
+ type,
+ currentStreak: 0,
+ longestStreak: 0,
+ lastUpdated: new Date(),
+ startDate: new Date()
+ };
+ this.streaks.push(streak);
+ streak = this.streaks[this.streaks.length - 1];
}
-
- // Check if this is a new day
- if (!lastUpdate || today > lastUpdate) {
- if (achieved) {
- const yesterday = new Date(today);
- yesterday.setDate(yesterday.getDate() - 1);
-
- if (!lastUpdate || lastUpdate.getTime() === yesterday.getTime()) {
- streak.current++;
- } else {
- streak.current = 1;
- }
-
- if (streak.current > streak.longest) {
- streak.longest = streak.current;
- }
+
+ const now = new Date();
+ const lastUpdate = new Date(streak.lastUpdated);
+ const daysSinceUpdate = Math.floor((now - lastUpdate) / (1000 * 60 * 60 * 24));
+
+ if (increment) {
+ if (daysSinceUpdate <= 1) {
+ streak.currentStreak++;
} else {
- streak.current = 0;
+ streak.currentStreak = 1;
+ streak.startDate = now;
}
- streak.lastUpdated = today;
+ if (streak.currentStreak > streak.longestStreak) {
+ streak.longestStreak = streak.currentStreak;
+ }
+ } else {
+ streak.currentStreak = 0;
+ streak.startDate = null;
}
-
- return streak;
-};
-
-// Reset monthly points (called by cron)
-userGamificationSchema.methods.resetMonthlyPoints = function() {
- const now = new Date();
- const lastReset = this.points.lastMonthReset;
- if (!lastReset ||
- now.getMonth() !== lastReset.getMonth() ||
- now.getFullYear() !== lastReset.getFullYear()) {
- this.points.currentMonth = 0;
- this.points.lastMonthReset = now;
- }
+ streak.lastUpdated = now;
+ return streak;
};
-// Get rank title based on level
-userGamificationSchema.virtual('rankTitle').get(function() {
- const level = this.level.current;
- if (level >= 50) return 'Finance Legend';
- if (level >= 40) return 'Money Master';
- if (level >= 30) return 'Budget Expert';
- if (level >= 20) return 'Savings Pro';
- if (level >= 15) return 'Expense Tracker';
- if (level >= 10) return 'Budget Warrior';
- if (level >= 5) return 'Money Rookie';
- return 'Finance Beginner';
-});
-
module.exports = mongoose.model('UserGamification', userGamificationSchema);
diff --git a/public/challenges.html b/public/challenges.html
new file mode 100644
index 0000000..f56246c
--- /dev/null
+++ b/public/challenges.html
@@ -0,0 +1,1245 @@
+
+
+
+
+
+ Challenges & Achievements - ExpenseFlow
+
+
+
+
+
+
+
+
+
+
+
Loading gamification data...
+
+
+
+
+
+
+
+
+
diff --git a/public/gamification.js b/public/gamification.js
new file mode 100644
index 0000000..5677a37
--- /dev/null
+++ b/public/gamification.js
@@ -0,0 +1,1171 @@
+/**
+ * Gamification Feature Module
+ * Handles challenges, achievements, leaderboards, and streaks
+ */
+
+class GamificationManager {
+ constructor() {
+ this.profile = null;
+ this.achievements = {};
+ this.challenges = [];
+ this.leaderboard = [];
+ this.templates = [];
+ this.baseUrl = '/api/gamification';
+ }
+
+ /**
+ * Get authorization headers
+ */
+ getHeaders() {
+ const token = localStorage.getItem('token');
+ return {
+ 'Content-Type': 'application/json',
+ 'Authorization': `Bearer ${token}`
+ };
+ }
+
+ /**
+ * Initialize gamification
+ */
+ async init() {
+ try {
+ await Promise.all([
+ this.loadProfile(),
+ this.loadAchievements(),
+ this.loadChallenges(),
+ this.loadTemplates()
+ ]);
+
+ this.setupSocketListeners();
+ this.renderDashboard();
+
+ return true;
+ } catch (error) {
+ console.error('Gamification init error:', error);
+ return false;
+ }
+ }
+
+ /**
+ * Load user profile
+ */
+ async loadProfile() {
+ try {
+ const response = await fetch(`${this.baseUrl}/profile`, {
+ headers: this.getHeaders()
+ });
+
+ const data = await response.json();
+
+ if (data.success) {
+ this.profile = data.data;
+ this.updateProfileUI();
+ }
+
+ return this.profile;
+ } catch (error) {
+ console.error('Load profile error:', error);
+ throw error;
+ }
+ }
+
+ /**
+ * Load achievements
+ */
+ async loadAchievements() {
+ try {
+ const response = await fetch(`${this.baseUrl}/achievements`, {
+ headers: this.getHeaders()
+ });
+
+ const data = await response.json();
+
+ if (data.success) {
+ this.achievements = data.data.achievements;
+ this.achievementSummary = data.data.summary;
+ this.renderAchievements();
+ }
+
+ return this.achievements;
+ } catch (error) {
+ console.error('Load achievements error:', error);
+ throw error;
+ }
+ }
+
+ /**
+ * Load user challenges
+ */
+ async loadChallenges(status = null) {
+ try {
+ const url = status
+ ? `${this.baseUrl}/challenges?status=${status}`
+ : `${this.baseUrl}/challenges`;
+
+ const response = await fetch(url, {
+ headers: this.getHeaders()
+ });
+
+ const data = await response.json();
+
+ if (data.success) {
+ this.challenges = data.data;
+ this.renderChallenges();
+ }
+
+ return this.challenges;
+ } catch (error) {
+ console.error('Load challenges error:', error);
+ throw error;
+ }
+ }
+
+ /**
+ * Load challenge templates
+ */
+ async loadTemplates() {
+ try {
+ const response = await fetch(`${this.baseUrl}/challenges/templates`, {
+ headers: this.getHeaders()
+ });
+
+ const data = await response.json();
+
+ if (data.success) {
+ this.templates = data.data;
+ }
+
+ return this.templates;
+ } catch (error) {
+ console.error('Load templates error:', error);
+ throw error;
+ }
+ }
+
+ /**
+ * Load leaderboard
+ */
+ async loadLeaderboard(type = 'all_time') {
+ try {
+ const response = await fetch(`${this.baseUrl}/leaderboard?type=${type}`, {
+ headers: this.getHeaders()
+ });
+
+ const data = await response.json();
+
+ if (data.success) {
+ this.leaderboard = data.data.entries;
+ this.renderLeaderboard(type);
+ }
+
+ return this.leaderboard;
+ } catch (error) {
+ console.error('Load leaderboard error:', error);
+ throw error;
+ }
+ }
+
+ /**
+ * Load friends leaderboard
+ */
+ async loadFriendsLeaderboard() {
+ try {
+ const response = await fetch(`${this.baseUrl}/leaderboard/friends`, {
+ headers: this.getHeaders()
+ });
+
+ const data = await response.json();
+
+ if (data.success) {
+ this.renderLeaderboard('friends', data.data);
+ }
+
+ return data.data;
+ } catch (error) {
+ console.error('Load friends leaderboard error:', error);
+ throw error;
+ }
+ }
+
+ /**
+ * Discover public challenges
+ */
+ async discoverChallenges(filters = {}) {
+ try {
+ const params = new URLSearchParams(filters).toString();
+ const response = await fetch(`${this.baseUrl}/challenges/discover?${params}`, {
+ headers: this.getHeaders()
+ });
+
+ const data = await response.json();
+
+ if (data.success) {
+ this.renderDiscoverChallenges(data.data);
+ }
+
+ return data.data;
+ } catch (error) {
+ console.error('Discover challenges error:', error);
+ throw error;
+ }
+ }
+
+ /**
+ * Create a challenge
+ */
+ async createChallenge(challengeData) {
+ try {
+ const response = await fetch(`${this.baseUrl}/challenges`, {
+ method: 'POST',
+ headers: this.getHeaders(),
+ body: JSON.stringify(challengeData)
+ });
+
+ const data = await response.json();
+
+ if (data.success) {
+ this.showNotification('Challenge created successfully!', 'success');
+ await this.loadChallenges();
+ this.closeModal();
+ return data.data;
+ } else {
+ this.showNotification(data.error || 'Failed to create challenge', 'error');
+ return null;
+ }
+ } catch (error) {
+ console.error('Create challenge error:', error);
+ this.showNotification('Failed to create challenge', 'error');
+ throw error;
+ }
+ }
+
+ /**
+ * Join a challenge
+ */
+ async joinChallenge(challengeId) {
+ try {
+ const response = await fetch(`${this.baseUrl}/challenges/${challengeId}/join`, {
+ method: 'POST',
+ headers: this.getHeaders()
+ });
+
+ const data = await response.json();
+
+ if (data.success) {
+ this.showNotification('Joined challenge successfully!', 'success');
+ await this.loadChallenges();
+ await this.loadProfile();
+ return data.data;
+ } else {
+ this.showNotification(data.error || 'Failed to join challenge', 'error');
+ return null;
+ }
+ } catch (error) {
+ console.error('Join challenge error:', error);
+ this.showNotification('Failed to join challenge', 'error');
+ throw error;
+ }
+ }
+
+ /**
+ * Leave a challenge
+ */
+ async leaveChallenge(challengeId) {
+ try {
+ const response = await fetch(`${this.baseUrl}/challenges/${challengeId}/leave`, {
+ method: 'POST',
+ headers: this.getHeaders()
+ });
+
+ const data = await response.json();
+
+ if (data.success) {
+ this.showNotification('Left challenge', 'info');
+ await this.loadChallenges();
+ return true;
+ } else {
+ this.showNotification(data.error || 'Failed to leave challenge', 'error');
+ return false;
+ }
+ } catch (error) {
+ console.error('Leave challenge error:', error);
+ throw error;
+ }
+ }
+
+ /**
+ * Get challenge details
+ */
+ async getChallengeDetails(challengeId) {
+ try {
+ const response = await fetch(`${this.baseUrl}/challenges/${challengeId}`, {
+ headers: this.getHeaders()
+ });
+
+ const data = await response.json();
+
+ if (data.success) {
+ this.renderChallengeDetails(data.data);
+ return data.data;
+ }
+
+ return null;
+ } catch (error) {
+ console.error('Get challenge details error:', error);
+ throw error;
+ }
+ }
+
+ /**
+ * Update privacy settings
+ */
+ async updatePrivacy(settings) {
+ try {
+ const response = await fetch(`${this.baseUrl}/privacy`, {
+ method: 'PATCH',
+ headers: this.getHeaders(),
+ body: JSON.stringify(settings)
+ });
+
+ const data = await response.json();
+
+ if (data.success) {
+ this.showNotification('Privacy settings updated', 'success');
+ this.profile.privacySettings = data.data;
+ }
+
+ return data.success;
+ } catch (error) {
+ console.error('Update privacy error:', error);
+ throw error;
+ }
+ }
+
+ /**
+ * Setup socket listeners for real-time updates
+ */
+ setupSocketListeners() {
+ if (typeof io !== 'undefined' && window.socket) {
+ window.socket.on('points_earned', (data) => {
+ this.showPointsAnimation(data.points, data.reason);
+ this.loadProfile();
+ });
+
+ window.socket.on('achievement_unlocked', (data) => {
+ this.showAchievementUnlocked(data);
+ this.loadAchievements();
+ });
+
+ window.socket.on('challenge_completed', (data) => {
+ this.showChallengeCompleted(data);
+ this.loadChallenges();
+ this.loadProfile();
+ });
+
+ window.socket.on('challenge_invitation', (data) => {
+ this.showChallengeInvitation(data);
+ });
+ }
+ }
+
+ // ==================== UI RENDERING ====================
+
+ /**
+ * Update profile UI
+ */
+ updateProfileUI() {
+ if (!this.profile) return;
+
+ const levelEl = document.getElementById('gamification-level');
+ const pointsEl = document.getElementById('gamification-points');
+ const rankEl = document.getElementById('gamification-rank');
+ const xpBarEl = document.getElementById('gamification-xp-bar');
+
+ if (levelEl) levelEl.textContent = this.profile.level;
+ if (pointsEl) pointsEl.textContent = this.formatNumber(this.profile.totalPoints);
+ if (rankEl) rankEl.textContent = this.capitalizeFirst(this.profile.rank);
+
+ if (xpBarEl) {
+ const xpPercent = ((this.profile.experience) /
+ (this.profile.experience + this.profile.experienceToNextLevel)) * 100;
+ xpBarEl.style.width = `${xpPercent}%`;
+ }
+
+ // Update streaks display
+ this.renderStreaks();
+ }
+
+ /**
+ * Render main dashboard
+ */
+ renderDashboard() {
+ const container = document.getElementById('gamification-dashboard');
+ if (!container) return;
+
+ container.innerHTML = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ${this.achievementSummary?.earned || 0} /
+ ${this.achievementSummary?.total || 0} Earned
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+
+ this.setupTabListeners();
+ this.updateProfileUI();
+ this.renderChallenges();
+ this.renderAchievements();
+ }
+
+ /**
+ * Setup tab listeners
+ */
+ setupTabListeners() {
+ const tabs = document.querySelectorAll('.tab-btn');
+
+ tabs.forEach(tab => {
+ tab.addEventListener('click', () => {
+ tabs.forEach(t => t.classList.remove('active'));
+ tab.classList.add('active');
+
+ document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
+ document.getElementById(`${tab.dataset.tab}-tab`).classList.add('active');
+
+ // Load data for tab
+ switch (tab.dataset.tab) {
+ case 'leaderboard':
+ this.loadLeaderboard();
+ break;
+ case 'achievements':
+ this.loadAchievements();
+ break;
+ }
+ });
+ });
+
+ // Leaderboard filter listeners
+ document.querySelectorAll('.filter-btn').forEach(btn => {
+ btn.addEventListener('click', () => {
+ document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
+ btn.classList.add('active');
+
+ if (btn.dataset.type === 'friends') {
+ this.loadFriendsLeaderboard();
+ } else {
+ this.loadLeaderboard(btn.dataset.type);
+ }
+ });
+ });
+ }
+
+ /**
+ * Render challenges list
+ */
+ renderChallenges() {
+ const container = document.getElementById('challenges-list');
+ if (!container) return;
+
+ if (!this.challenges.length) {
+ container.innerHTML = `
+
+
๐ฏ
+
No challenges yet
+
Create a challenge or discover public challenges to get started!
+
+
+ `;
+ return;
+ }
+
+ container.innerHTML = this.challenges.map(challenge => `
+
+
+
${challenge.title}
+
${challenge.description}
+
+
+
${challenge.userProgress}%
+
+
+
+ `).join('');
+ }
+
+ /**
+ * Render discover challenges
+ */
+ renderDiscoverChallenges(challenges) {
+ const modal = document.createElement('div');
+ modal.className = 'modal-overlay';
+ modal.id = 'discover-modal';
+
+ modal.innerHTML = `
+
+
+
+
+
+
+
+
+ ${challenges.length ? challenges.map(c => `
+
+
+
${c.title}
+
${c.description}
+
+ ๐ฅ ${c.participantCount} participants
+ โฐ ${c.daysRemaining} days left
+
+
+${c.rewardPoints} points
+ ${c.isJoined
+ ? '
'
+ : `
`
+ }
+
+ `).join('') : '
No challenges found
'}
+
+
+
+ `;
+
+ document.body.appendChild(modal);
+ }
+
+ /**
+ * Render achievements
+ */
+ renderAchievements() {
+ const container = document.getElementById('achievements-list');
+ if (!container) return;
+
+ const categories = Object.keys(this.achievements);
+
+ if (!categories.length) {
+ container.innerHTML = 'Loading achievements...
';
+ return;
+ }
+
+ container.innerHTML = categories.map(category => `
+
+
${this.capitalizeFirst(category)}
+
+ ${this.achievements[category].map(a => `
+
+
${a.icon}
+
+
${a.name}
+
${a.description}
+ ${!a.isEarned && !a.isSecret ? `
+
+
+
${a.currentValue}/${a.targetValue}
+
+ ` : ''}
+ ${a.isEarned ? `
Earned ${this.formatDate(a.earnedAt)}` : ''}
+
+
+${a.points || 0}
+
+ `).join('')}
+
+
+ `).join('');
+ }
+
+ /**
+ * Render leaderboard
+ */
+ renderLeaderboard(type, data = null) {
+ const container = document.getElementById('leaderboard-list');
+ if (!container) return;
+
+ const entries = data || this.leaderboard;
+
+ if (!entries.length) {
+ container.innerHTML = 'No leaderboard data available
';
+ return;
+ }
+
+ container.innerHTML = `
+
+ ${entries.map((entry, index) => `
+
+
+ ${index < 3 ? ['๐ฅ', '๐ฅ', '๐ฅ'][index] : entry.rank}
+
+
+ ${entry.name} ${entry.isCurrentUser ? '(You)' : ''}
+ ${this.capitalizeFirst(entry.rankTitle)}
+
+
+ Lv. ${entry.level}
+ ${this.formatNumber(entry.points)} pts
+
+
+ `).join('')}
+
+ `;
+ }
+
+ /**
+ * Render streaks
+ */
+ renderStreaks() {
+ const container = document.getElementById('streaks-list');
+ if (!container || !this.profile) return;
+
+ const streakTypes = [
+ { type: 'login', icon: '๐
', name: 'Login Streak', desc: 'Days logged in consecutively' },
+ { type: 'expense_tracking', icon: '๐', name: 'Tracking Streak', desc: 'Days tracking expenses' },
+ { type: 'budget_adherence', icon: '๐ฐ', name: 'Budget Streak', desc: 'Days staying under budget' },
+ { type: 'no_spend', icon: '๐ฑ', name: 'No Spend Streak', desc: 'Consecutive no-spend days' },
+ { type: 'savings', icon: '๐', name: 'Savings Streak', desc: 'Days saving money' }
+ ];
+
+ container.innerHTML = `
+
+ ${streakTypes.map(st => {
+ const streak = this.profile.streaks?.find(s => s.type === st.type);
+ return `
+
+
${st.icon}
+
+
${st.name}
+
${st.desc}
+
+
+
+ ${streak?.currentStreak || 0}
+ Current
+
+
+ ${streak?.longestStreak || 0}
+ Best
+
+
+ ${streak?.currentStreak > 0 ? '
๐ฅ' : ''}
+
+ `;
+ }).join('')}
+
+ `;
+ }
+
+ /**
+ * Render challenge details modal
+ */
+ renderChallengeDetails(challenge) {
+ const modal = document.createElement('div');
+ modal.className = 'modal-overlay';
+ modal.id = 'challenge-details-modal';
+
+ modal.innerHTML = `
+
+
+
+
${challenge.description}
+
+ ${challenge.rules ? `
Rules: ${challenge.rules}
` : ''}
+
+
+
+ Difficulty
+ ${challenge.difficulty}
+
+
+ Type
+ ${challenge.type.replace('_', ' ')}
+
+
+ Target
+ ${challenge.targetValue} ${challenge.targetUnit}
+
+
+ Reward
+ +${challenge.rewardPoints} points
+
+
+ Participants
+ ${challenge.participantCount}
+
+
+ Days Left
+ ${challenge.daysRemaining}
+
+
+
+ ${challenge.isParticipant ? `
+
+
Your Progress
+
+
+
${challenge.userProgress}%
+
+ ${challenge.userSavedAmount > 0 ? `
Amount saved: โน${challenge.userSavedAmount.toLocaleString()}
` : ''}
+ ${challenge.userStreak > 0 ? `
Current streak: ${challenge.userStreak} days ๐ฅ
` : ''}
+
+ ` : ''}
+
+ ${challenge.leaderboard?.length ? `
+
+
Top Participants
+
+ ${challenge.leaderboard.map((p, i) => `
+
+ ${i + 1}
+ ${p.name}
+ ${p.progress}%
+
+ `).join('')}
+
+
+ ` : ''}
+
+
+
+ `;
+
+ document.body.appendChild(modal);
+ }
+
+ /**
+ * Show create challenge modal
+ */
+ showCreateChallengeModal() {
+ const modal = document.createElement('div');
+ modal.className = 'modal-overlay';
+ modal.id = 'create-challenge-modal';
+
+ modal.innerHTML = `
+
+
+
+
+
Quick Start Templates
+
+ ${this.templates.slice(0, 6).map(t => `
+
+ `).join('')}
+
+
+
+
or create custom
+
+
+
+
+ `;
+
+ document.body.appendChild(modal);
+
+ // Set min date to today
+ const today = new Date().toISOString().split('T')[0];
+ document.getElementById('challenge-start').min = today;
+ document.getElementById('challenge-start').value = today;
+
+ const nextWeek = new Date();
+ nextWeek.setDate(nextWeek.getDate() + 7);
+ document.getElementById('challenge-end').min = today;
+ document.getElementById('challenge-end').value = nextWeek.toISOString().split('T')[0];
+ }
+
+ /**
+ * Use template to fill form
+ */
+ useTemplate(templateTitle) {
+ const template = this.templates.find(t => t.title === templateTitle);
+ if (!template) return;
+
+ document.getElementById('challenge-title').value = template.title;
+ document.getElementById('challenge-desc').value = template.description;
+ document.getElementById('challenge-type').value = template.type;
+ document.getElementById('challenge-category').value = template.category || 'all';
+ document.getElementById('challenge-target').value = template.targetValue;
+ document.getElementById('challenge-unit').value = template.targetUnit;
+ document.getElementById('challenge-difficulty').value = template.difficulty;
+ document.getElementById('challenge-points').value = template.rewardPoints;
+
+ // Update end date based on suggested duration
+ if (template.suggestedDuration) {
+ const startDate = new Date(document.getElementById('challenge-start').value);
+ const endDate = new Date(startDate);
+ endDate.setDate(endDate.getDate() + template.suggestedDuration);
+ document.getElementById('challenge-end').value = endDate.toISOString().split('T')[0];
+ }
+ }
+
+ /**
+ * Handle create challenge form submission
+ */
+ async handleCreateChallenge(event) {
+ event.preventDefault();
+
+ const challengeData = {
+ title: document.getElementById('challenge-title').value,
+ description: document.getElementById('challenge-desc').value,
+ type: document.getElementById('challenge-type').value,
+ category: document.getElementById('challenge-category').value,
+ targetValue: parseInt(document.getElementById('challenge-target').value),
+ targetUnit: document.getElementById('challenge-unit').value,
+ startDate: new Date(document.getElementById('challenge-start').value).toISOString(),
+ endDate: new Date(document.getElementById('challenge-end').value).toISOString(),
+ difficulty: document.getElementById('challenge-difficulty').value,
+ rewardPoints: parseInt(document.getElementById('challenge-points').value),
+ isPublic: document.getElementById('challenge-public').checked
+ };
+
+ await this.createChallenge(challengeData);
+ }
+
+ /**
+ * Close modal
+ */
+ closeModal() {
+ const modals = document.querySelectorAll('.modal-overlay');
+ modals.forEach(m => m.remove());
+ }
+
+ // ==================== ANIMATIONS & NOTIFICATIONS ====================
+
+ /**
+ * Show points animation
+ */
+ showPointsAnimation(points, reason) {
+ const animation = document.createElement('div');
+ animation.className = 'points-animation';
+ animation.innerHTML = `+${points} pts`;
+ animation.style.cssText = `
+ position: fixed;
+ top: 20%;
+ left: 50%;
+ transform: translateX(-50%);
+ font-size: 2rem;
+ font-weight: bold;
+ color: #FFD700;
+ text-shadow: 2px 2px 4px rgba(0,0,0,0.5);
+ animation: floatUp 2s ease-out forwards;
+ z-index: 10000;
+ `;
+
+ document.body.appendChild(animation);
+
+ setTimeout(() => animation.remove(), 2000);
+ }
+
+ /**
+ * Show achievement unlocked
+ */
+ showAchievementUnlocked(achievement) {
+ const popup = document.createElement('div');
+ popup.className = 'achievement-popup';
+ popup.innerHTML = `
+
+ `;
+
+ document.body.appendChild(popup);
+
+ setTimeout(() => {
+ popup.classList.add('fade-out');
+ setTimeout(() => popup.remove(), 500);
+ }, 4000);
+ }
+
+ /**
+ * Show challenge completed
+ */
+ showChallengeCompleted(data) {
+ const popup = document.createElement('div');
+ popup.className = 'challenge-complete-popup';
+ popup.innerHTML = `
+
+
๐
+
Challenge Completed!
+
${data.challengeTitle}
+
+${data.points} points
+
+ `;
+
+ document.body.appendChild(popup);
+
+ setTimeout(() => {
+ popup.classList.add('fade-out');
+ setTimeout(() => popup.remove(), 500);
+ }, 5000);
+ }
+
+ /**
+ * Show challenge invitation
+ */
+ showChallengeInvitation(data) {
+ this.showNotification(
+ `${data.invitedBy} invited you to "${data.challengeTitle}"`,
+ 'info',
+ () => this.getChallengeDetails(data.challengeId)
+ );
+ }
+
+ /**
+ * Show notification
+ */
+ showNotification(message, type = 'info', onClick = null) {
+ const notification = document.createElement('div');
+ notification.className = `notification notification-${type}`;
+ notification.textContent = message;
+
+ if (onClick) {
+ notification.style.cursor = 'pointer';
+ notification.addEventListener('click', onClick);
+ }
+
+ document.body.appendChild(notification);
+
+ setTimeout(() => {
+ notification.classList.add('fade-out');
+ setTimeout(() => notification.remove(), 300);
+ }, 3000);
+ }
+
+ // ==================== UTILITY METHODS ====================
+
+ formatNumber(num) {
+ if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M';
+ if (num >= 1000) return (num / 1000).toFixed(1) + 'K';
+ return num.toString();
+ }
+
+ formatDate(dateStr) {
+ const date = new Date(dateStr);
+ return date.toLocaleDateString('en-US', {
+ month: 'short',
+ day: 'numeric',
+ year: 'numeric'
+ });
+ }
+
+ capitalizeFirst(str) {
+ return str.charAt(0).toUpperCase() + str.slice(1);
+ }
+
+ filterDiscoverChallenges() {
+ const type = document.getElementById('filter-type').value;
+ const difficulty = document.getElementById('filter-difficulty').value;
+
+ this.discoverChallenges({ type, difficulty });
+ }
+}
+
+// Initialize
+const gamification = new GamificationManager();
+
+// Export for module usage
+if (typeof module !== 'undefined' && module.exports) {
+ module.exports = { GamificationManager };
+}
diff --git a/routes/gamification.js b/routes/gamification.js
index 27eb973..ed3ee0f 100644
--- a/routes/gamification.js
+++ b/routes/gamification.js
@@ -1,10 +1,19 @@
const express = require('express');
-const router = express.Router();
const auth = require('../middleware/auth');
-const { validateChallengeCreate, validateObjectId } = require('../middleware/gamificationValidator');
const gamificationService = require('../services/gamificationService');
+const Challenge = require('../models/Challenge');
+const {
+ validateChallenge,
+ validateChallengeUpdate,
+ validateProgressUpdate,
+ validatePrivacySettings,
+ validateLeaderboardQuery,
+ validateChallengeFilters
+} = require('../middleware/gamificationValidator');
+
+const router = express.Router();
-// ========== USER PROFILE ROUTES ==========
+// ==================== PROFILE ROUTES ====================
/**
* @route GET /api/gamification/profile
@@ -12,192 +21,392 @@ const gamificationService = require('../services/gamificationService');
* @access Private
*/
router.get('/profile', auth, async (req, res) => {
- try {
- const profile = await gamificationService.getUserProfile(req.user._id);
-
- res.json({
- success: true,
- data: {
- points: profile.points,
- level: profile.level,
- rankTitle: profile.rankTitle,
- streaks: profile.streaks,
- stats: profile.stats,
- badges: profile.badges.slice(0, 10),
- featuredBadges: profile.featuredBadges
- }
- });
- } catch (error) {
- console.error('[Gamification] Profile error:', error);
- res.status(500).json({ error: error.message });
- }
+ try {
+ const profile = await gamificationService.getOrCreateProfile(req.user._id);
+
+ // Track login for streak
+ await gamificationService.trackLogin(req.user._id);
+
+ res.json({
+ success: true,
+ data: {
+ totalPoints: profile.totalPoints,
+ level: profile.level,
+ experience: profile.experience,
+ experienceToNextLevel: profile.experienceToNextLevel,
+ rank: profile.rank,
+ weeklyPoints: profile.weeklyPoints,
+ monthlyPoints: profile.monthlyPoints,
+ challengesCompleted: profile.challengesCompleted,
+ challengesJoined: profile.challengesJoined,
+ totalSavedFromChallenges: profile.totalSavedFromChallenges,
+ achievementCount: profile.earnedAchievements.length,
+ streaks: profile.streaks,
+ stats: profile.stats,
+ privacySettings: profile.privacySettings
+ }
+ });
+ } catch (error) {
+ res.status(500).json({ error: error.message });
+ }
});
/**
- * @route POST /api/gamification/track/dashboard
- * @desc Track dashboard visit for achievements
+ * @route PATCH /api/gamification/privacy
+ * @desc Update privacy settings
* @access Private
*/
-router.post('/track/dashboard', auth, async (req, res) => {
- try {
- await gamificationService.trackDashboardVisit(req.user._id);
- res.json({ success: true, message: 'Dashboard visit tracked' });
- } catch (error) {
- console.error('[Gamification] Track dashboard error:', error);
- res.status(500).json({ error: error.message });
- }
+router.patch('/privacy', auth, validatePrivacySettings, async (req, res) => {
+ try {
+ const profile = await gamificationService.getOrCreateProfile(req.user._id);
+
+ Object.assign(profile.privacySettings, req.body);
+ await profile.save();
+
+ res.json({
+ success: true,
+ data: profile.privacySettings
+ });
+ } catch (error) {
+ res.status(500).json({ error: error.message });
+ }
});
-// ========== ACHIEVEMENT ROUTES ==========
+// ==================== ACHIEVEMENTS ROUTES ====================
/**
* @route GET /api/gamification/achievements
- * @desc Get all available achievements
+ * @desc Get all achievements with user progress
* @access Private
*/
router.get('/achievements', auth, async (req, res) => {
- try {
- const { category, rarity } = req.query;
- const achievements = await gamificationService.getAchievements({ category, rarity });
-
- res.json({
- success: true,
- count: achievements.length,
- data: achievements
- });
- } catch (error) {
- console.error('[Gamification] Get achievements error:', error);
- res.status(500).json({ error: error.message });
- }
+ try {
+ const achievements = await gamificationService.getUserAchievements(req.user._id);
+
+ // Group by category
+ const grouped = achievements.reduce((acc, achievement) => {
+ if (!acc[achievement.category]) {
+ acc[achievement.category] = [];
+ }
+ acc[achievement.category].push(achievement);
+ return acc;
+ }, {});
+
+ const summary = {
+ total: achievements.length,
+ earned: achievements.filter(a => a.isEarned).length,
+ inProgress: achievements.filter(a => !a.isEarned && a.progress > 0).length
+ };
+
+ res.json({
+ success: true,
+ data: {
+ achievements: grouped,
+ summary
+ }
+ });
+ } catch (error) {
+ res.status(500).json({ error: error.message });
+ }
});
/**
- * @route GET /api/gamification/achievements/user
- * @desc Get user's achievement progress
+ * @route GET /api/gamification/achievements/recent
+ * @desc Get recently earned achievements
* @access Private
*/
-router.get('/achievements/user', auth, async (req, res) => {
- try {
- const { status, category } = req.query;
- const achievements = await gamificationService.getUserAchievements(req.user._id, {
- status,
- category
- });
+router.get('/achievements/recent', auth, async (req, res) => {
+ try {
+ const profile = await gamificationService.getOrCreateProfile(req.user._id);
+
+ const recentAchievements = await Promise.all(
+ profile.earnedAchievements
+ .sort((a, b) => new Date(b.earnedAt) - new Date(a.earnedAt))
+ .slice(0, 5)
+ .map(async ea => {
+ const Achievement = require('../models/Achievement');
+ const achievement = await Achievement.findById(ea.achievement);
+ return {
+ ...achievement.toObject(),
+ earnedAt: ea.earnedAt
+ };
+ })
+ );
+
+ res.json({
+ success: true,
+ data: recentAchievements
+ });
+ } catch (error) {
+ res.status(500).json({ error: error.message });
+ }
+});
- // Separate completed and in-progress
- const completed = achievements.filter(a => a.status === 'completed');
- const inProgress = achievements.filter(a => a.status === 'in_progress');
+// ==================== LEADERBOARD ROUTES ====================
- res.json({
- success: true,
- data: {
- completed: completed.length,
- inProgress: inProgress.length,
- achievements
- }
- });
- } catch (error) {
- console.error('[Gamification] Get user achievements error:', error);
- res.status(500).json({ error: error.message });
- }
+/**
+ * @route GET /api/gamification/leaderboard
+ * @desc Get leaderboard
+ * @access Private
+ */
+router.get('/leaderboard', auth, validateLeaderboardQuery, async (req, res) => {
+ try {
+ const { type, limit } = req.query;
+ const leaderboard = await gamificationService.getLeaderboard(type, limit, req.user._id);
+
+ res.json({
+ success: true,
+ data: {
+ type,
+ entries: leaderboard
+ }
+ });
+ } catch (error) {
+ res.status(500).json({ error: error.message });
+ }
});
/**
- * @route POST /api/gamification/achievements/check
- * @desc Check and award any new achievements
+ * @route GET /api/gamification/leaderboard/friends
+ * @desc Get leaderboard among friends (users in same workspace)
* @access Private
*/
-router.post('/achievements/check', auth, async (req, res) => {
- try {
- const earnedAchievements = await gamificationService.checkAchievements(req.user._id);
-
- res.json({
- success: true,
- message: earnedAchievements.length > 0
- ? `Earned ${earnedAchievements.length} new achievement(s)!`
- : 'No new achievements',
- data: earnedAchievements
- });
- } catch (error) {
- console.error('[Gamification] Check achievements error:', error);
- res.status(500).json({ error: error.message });
- }
+router.get('/leaderboard/friends', auth, async (req, res) => {
+ try {
+ const Workspace = require('../models/Workspace');
+ const UserGamification = require('../models/UserGamification');
+
+ // Find workspaces user is part of
+ const workspaces = await Workspace.find({
+ $or: [
+ { owner: req.user._id },
+ { 'members.user': req.user._id }
+ ]
+ });
+
+ // Get all user IDs from those workspaces
+ const userIds = new Set([req.user._id.toString()]);
+ workspaces.forEach(ws => {
+ userIds.add(ws.owner.toString());
+ ws.members.forEach(m => userIds.add(m.user.toString()));
+ });
+
+ // Get gamification profiles for these users
+ const profiles = await UserGamification.find({
+ user: { $in: Array.from(userIds) },
+ 'privacySettings.showOnLeaderboard': true
+ })
+ .populate('user', 'name')
+ .sort({ totalPoints: -1 });
+
+ const leaderboard = profiles.map((p, index) => ({
+ rank: index + 1,
+ userId: p.user._id,
+ name: p.user.name,
+ points: p.totalPoints,
+ level: p.level,
+ rankTitle: p.rank,
+ isCurrentUser: p.user._id.toString() === req.user._id.toString()
+ }));
+
+ res.json({
+ success: true,
+ data: leaderboard
+ });
+ } catch (error) {
+ res.status(500).json({ error: error.message });
+ }
});
-// ========== CHALLENGE ROUTES ==========
+// ==================== CHALLENGE ROUTES ====================
+
+/**
+ * @route POST /api/gamification/challenges
+ * @desc Create a new challenge
+ * @access Private
+ */
+router.post('/challenges', auth, validateChallenge, async (req, res) => {
+ try {
+ const challenge = await gamificationService.createChallenge(req.user._id, req.body);
+
+ res.status(201).json({
+ success: true,
+ data: challenge
+ });
+ } catch (error) {
+ res.status(500).json({ error: error.message });
+ }
+});
/**
* @route GET /api/gamification/challenges
- * @desc Get available challenges
+ * @desc Get user's challenges
* @access Private
*/
router.get('/challenges', auth, async (req, res) => {
- try {
- const { status, scope, type } = req.query;
- const challenges = await gamificationService.getChallenges({
- status: status || 'active',
- scope,
- type,
- userId: req.user._id
- });
-
- res.json({
- success: true,
- count: challenges.length,
- data: challenges
- });
- } catch (error) {
- console.error('[Gamification] Get challenges error:', error);
- res.status(500).json({ error: error.message });
- }
+ try {
+ const { status } = req.query;
+ const challenges = await gamificationService.getUserChallenges(req.user._id, status);
+
+ res.json({
+ success: true,
+ data: challenges
+ });
+ } catch (error) {
+ res.status(500).json({ error: error.message });
+ }
});
/**
- * @route POST /api/gamification/challenges
- * @desc Create a new challenge
+ * @route GET /api/gamification/challenges/discover
+ * @desc Get public challenges to join
* @access Private
*/
-router.post('/challenges', auth, validateChallengeCreate, async (req, res) => {
- try {
- const challenge = await gamificationService.createChallenge(req.user._id, req.validatedBody);
-
- const io = req.app.get('io');
- io.to(`user_${req.user._id}`).emit('challenge_created', challenge);
-
- res.status(201).json({
- success: true,
- message: 'Challenge created successfully',
- data: challenge
- });
- } catch (error) {
- console.error('[Gamification] Create challenge error:', error);
- res.status(500).json({ error: error.message });
- }
+router.get('/challenges/discover', auth, validateChallengeFilters, async (req, res) => {
+ try {
+ const challenges = await gamificationService.getPublicChallenges(req.user._id, req.query);
+
+ res.json({
+ success: true,
+ data: challenges
+ });
+ } catch (error) {
+ res.status(500).json({ error: error.message });
+ }
});
/**
- * @route GET /api/gamification/challenges/user
- * @desc Get user's challenge participations
+ * @route GET /api/gamification/challenges/templates
+ * @desc Get challenge templates
* @access Private
*/
-router.get('/challenges/user', auth, async (req, res) => {
- try {
- const { status } = req.query;
- const statusArray = status ? status.split(',') : null;
-
- const participations = await gamificationService.getUserChallenges(req.user._id, {
- status: statusArray
- });
-
- res.json({
- success: true,
- count: participations.length,
- data: participations
- });
- } catch (error) {
- console.error('[Gamification] Get user challenges error:', error);
- res.status(500).json({ error: error.message });
- }
+router.get('/challenges/templates', auth, async (req, res) => {
+ try {
+ const templates = [
+ {
+ title: 'No Spend Weekend',
+ description: 'Challenge yourself to not spend anything for a weekend',
+ type: 'no_spend',
+ targetValue: 2,
+ targetUnit: 'days',
+ difficulty: 'easy',
+ rewardPoints: 50,
+ icon: '๐ซ๐ธ',
+ suggestedDuration: 2
+ },
+ {
+ title: 'No Spend Week',
+ description: 'Go a full week without any unnecessary spending',
+ type: 'no_spend',
+ targetValue: 7,
+ targetUnit: 'days',
+ difficulty: 'medium',
+ rewardPoints: 150,
+ icon: '๐',
+ suggestedDuration: 7
+ },
+ {
+ title: 'Coffee Shop Savings',
+ description: 'Reduce your coffee shop spending by 50% this month',
+ type: 'category_reduction',
+ category: 'coffee',
+ targetValue: 50,
+ targetUnit: 'percentage',
+ difficulty: 'medium',
+ rewardPoints: 100,
+ icon: 'โ',
+ suggestedDuration: 30
+ },
+ {
+ title: 'Meal Prep Month',
+ description: 'Cut food delivery expenses by 75% this month',
+ type: 'category_reduction',
+ category: 'food',
+ targetValue: 75,
+ targetUnit: 'percentage',
+ difficulty: 'hard',
+ rewardPoints: 200,
+ icon: '๐ณ',
+ suggestedDuration: 30
+ },
+ {
+ title: 'Entertainment Detox',
+ description: 'Reduce entertainment spending by 60% for two weeks',
+ type: 'category_reduction',
+ category: 'entertainment',
+ targetValue: 60,
+ targetUnit: 'percentage',
+ difficulty: 'medium',
+ rewardPoints: 120,
+ icon: '๐ฌ',
+ suggestedDuration: 14
+ },
+ {
+ title: 'Savings Sprint',
+ description: 'Save โน5,000 in one month',
+ type: 'savings_target',
+ targetValue: 5000,
+ targetUnit: 'amount',
+ difficulty: 'medium',
+ rewardPoints: 150,
+ icon: '๐ฐ',
+ suggestedDuration: 30
+ },
+ {
+ title: 'Budget Warrior',
+ description: 'Stay under budget for 30 consecutive days',
+ type: 'budget_adherence',
+ targetValue: 30,
+ targetUnit: 'days',
+ difficulty: 'hard',
+ rewardPoints: 250,
+ icon: 'โ๏ธ',
+ suggestedDuration: 30
+ },
+ {
+ title: 'Tracking Streak',
+ description: 'Track expenses every day for 14 days',
+ type: 'streak',
+ targetValue: 14,
+ targetUnit: 'days',
+ difficulty: 'easy',
+ rewardPoints: 75,
+ icon: '๐',
+ suggestedDuration: 14
+ },
+ {
+ title: 'Shopping Fast',
+ description: 'No shopping expenses for 10 days',
+ type: 'no_spend',
+ category: 'shopping',
+ targetValue: 10,
+ targetUnit: 'days',
+ difficulty: 'medium',
+ rewardPoints: 100,
+ icon: '๐๏ธ',
+ suggestedDuration: 10
+ },
+ {
+ title: 'Transport Saver',
+ description: 'Reduce transport costs by 40% this month',
+ type: 'category_reduction',
+ category: 'transport',
+ targetValue: 40,
+ targetUnit: 'percentage',
+ difficulty: 'medium',
+ rewardPoints: 100,
+ icon: '๐',
+ suggestedDuration: 30
+ }
+ ];
+
+ res.json({
+ success: true,
+ data: templates
+ });
+ } catch (error) {
+ res.status(500).json({ error: error.message });
+ }
});
/**
@@ -205,22 +414,62 @@ router.get('/challenges/user', auth, async (req, res) => {
* @desc Get challenge details
* @access Private
*/
-router.get('/challenges/:id', auth, validateObjectId, async (req, res) => {
- try {
- const challenge = await gamificationService.getChallengeById(req.params.id);
-
- if (!challenge) {
- return res.status(404).json({ error: 'Challenge not found' });
- }
-
- res.json({
- success: true,
- data: challenge
- });
- } catch (error) {
- console.error('[Gamification] Get challenge error:', error);
- res.status(500).json({ error: error.message });
+router.get('/challenges/:id', auth, async (req, res) => {
+ try {
+ const challenge = await Challenge.findById(req.params.id)
+ .populate('creator', 'name')
+ .populate('participants.user', 'name');
+
+ if (!challenge) {
+ return res.status(404).json({ error: 'Challenge not found' });
+ }
+
+ // Check if user can view this challenge
+ if (!challenge.isPublic && !challenge.isParticipant(req.user._id) &&
+ challenge.creator._id.toString() !== req.user._id.toString()) {
+ return res.status(403).json({ error: 'Access denied' });
}
+
+ const participant = challenge.getParticipant(req.user._id);
+
+ // Calculate progress if user is participant
+ let currentProgress = null;
+ if (participant && challenge.status === 'active') {
+ currentProgress = await gamificationService.calculateChallengeProgress(
+ req.user._id,
+ challenge._id
+ );
+ }
+
+ res.json({
+ success: true,
+ data: {
+ ...challenge.toObject(),
+ isParticipant: challenge.isParticipant(req.user._id),
+ isCreator: challenge.creator._id.toString() === req.user._id.toString(),
+ userProgress: participant?.progress || 0,
+ userStatus: participant?.status || null,
+ userStreak: participant?.currentStreak || 0,
+ userSavedAmount: participant?.savedAmount || 0,
+ currentProgress,
+ daysRemaining: challenge.getDaysRemaining(),
+ participantCount: challenge.participants.length,
+ leaderboard: challenge.participants
+ .filter(p => p.status !== 'withdrawn')
+ .sort((a, b) => b.progress - a.progress)
+ .slice(0, 10)
+ .map((p, index) => ({
+ rank: index + 1,
+ userId: p.user._id,
+ name: p.user.name,
+ progress: p.progress,
+ streak: p.currentStreak
+ }))
+ }
+ });
+ } catch (error) {
+ res.status(500).json({ error: error.message });
+ }
});
/**
@@ -228,25 +477,18 @@ router.get('/challenges/:id', auth, validateObjectId, async (req, res) => {
* @desc Join a challenge
* @access Private
*/
-router.post('/challenges/:id/join', auth, validateObjectId, async (req, res) => {
- try {
- const participant = await gamificationService.joinChallenge(req.user._id, req.params.id);
-
- const io = req.app.get('io');
- io.to(`user_${req.user._id}`).emit('challenge_joined', participant);
-
- res.json({
- success: true,
- message: 'Successfully joined the challenge!',
- data: participant
- });
- } catch (error) {
- console.error('[Gamification] Join challenge error:', error);
- if (error.message.includes('not found') || error.message.includes('not available') || error.message.includes('Already joined')) {
- return res.status(400).json({ error: error.message });
- }
- res.status(500).json({ error: error.message });
- }
+router.post('/challenges/:id/join', auth, async (req, res) => {
+ try {
+ const challenge = await gamificationService.joinChallenge(req.user._id, req.params.id);
+
+ res.json({
+ success: true,
+ message: 'Successfully joined the challenge',
+ data: challenge
+ });
+ } catch (error) {
+ res.status(400).json({ error: error.message });
+ }
});
/**
@@ -254,154 +496,286 @@ router.post('/challenges/:id/join', auth, validateObjectId, async (req, res) =>
* @desc Leave a challenge
* @access Private
*/
-router.post('/challenges/:id/leave', auth, validateObjectId, async (req, res) => {
- try {
- const participant = await gamificationService.leaveChallenge(req.user._id, req.params.id);
-
- const io = req.app.get('io');
- io.to(`user_${req.user._id}`).emit('challenge_left', { challengeId: req.params.id });
-
- res.json({
- success: true,
- message: 'Left the challenge',
- data: participant
- });
- } catch (error) {
- console.error('[Gamification] Leave challenge error:', error);
- if (error.message.includes('Not participating')) {
- return res.status(400).json({ error: error.message });
- }
- res.status(500).json({ error: error.message });
+router.post('/challenges/:id/leave', auth, async (req, res) => {
+ try {
+ const challenge = await Challenge.findById(req.params.id);
+
+ if (!challenge) {
+ return res.status(404).json({ error: 'Challenge not found' });
+ }
+
+ const participant = challenge.getParticipant(req.user._id);
+
+ if (!participant) {
+ return res.status(400).json({ error: 'Not a participant of this challenge' });
}
+
+ participant.status = 'withdrawn';
+ await challenge.save();
+
+ res.json({
+ success: true,
+ message: 'Successfully left the challenge'
+ });
+ } catch (error) {
+ res.status(500).json({ error: error.message });
+ }
});
/**
- * @route POST /api/gamification/challenges/:id/progress
- * @desc Update challenge progress
+ * @route PATCH /api/gamification/challenges/:id/progress
+ * @desc Update challenge progress (for custom challenges)
* @access Private
*/
-router.post('/challenges/:id/progress', auth, validateObjectId, async (req, res) => {
- try {
- const { increment, set, dailyValue, achieved } = req.body;
-
- const participant = await gamificationService.updateChallengeProgress(
- req.user._id,
- req.params.id,
- { increment, set, dailyValue, achieved }
- );
-
- const io = req.app.get('io');
- io.to(`user_${req.user._id}`).emit('challenge_progress', participant);
-
- res.json({
- success: true,
- message: 'Progress updated',
- data: participant
- });
- } catch (error) {
- console.error('[Gamification] Update progress error:', error);
- if (error.message.includes('Not actively participating')) {
- return res.status(400).json({ error: error.message });
- }
- res.status(500).json({ error: error.message });
- }
+router.patch('/challenges/:id/progress', auth, validateProgressUpdate, async (req, res) => {
+ try {
+ const challenge = await gamificationService.updateChallengeProgress(
+ req.user._id,
+ req.params.id,
+ req.body
+ );
+
+ res.json({
+ success: true,
+ data: challenge
+ });
+ } catch (error) {
+ res.status(400).json({ error: error.message });
+ }
});
/**
- * @route GET /api/gamification/challenges/:id/leaderboard
- * @desc Get challenge leaderboard
+ * @route PUT /api/gamification/challenges/:id
+ * @desc Update challenge (creator only)
* @access Private
*/
-router.get('/challenges/:id/leaderboard', auth, validateObjectId, async (req, res) => {
- try {
- const limit = parseInt(req.query.limit) || 10;
- const leaderboard = await gamificationService.getChallengeLeaderboard(req.params.id, limit);
-
- res.json({
- success: true,
- data: leaderboard
- });
- } catch (error) {
- console.error('[Gamification] Get challenge leaderboard error:', error);
- res.status(500).json({ error: error.message });
+router.put('/challenges/:id', auth, validateChallengeUpdate, async (req, res) => {
+ try {
+ const challenge = await Challenge.findById(req.params.id);
+
+ if (!challenge) {
+ return res.status(404).json({ error: 'Challenge not found' });
+ }
+
+ if (challenge.creator.toString() !== req.user._id.toString()) {
+ return res.status(403).json({ error: 'Only the creator can update this challenge' });
}
+
+ // Can only update certain fields after creation
+ const allowedUpdates = ['title', 'description', 'isPublic', 'rules', 'icon'];
+ const updates = {};
+
+ for (const field of allowedUpdates) {
+ if (req.body[field] !== undefined) {
+ updates[field] = req.body[field];
+ }
+ }
+
+ Object.assign(challenge, updates);
+ await challenge.save();
+
+ res.json({
+ success: true,
+ data: challenge
+ });
+ } catch (error) {
+ res.status(500).json({ error: error.message });
+ }
});
-// ========== LEADERBOARD ROUTES ==========
-
/**
- * @route GET /api/gamification/leaderboard
- * @desc Get global leaderboard
+ * @route DELETE /api/gamification/challenges/:id
+ * @desc Delete/cancel a challenge (creator only, before it starts)
* @access Private
*/
-router.get('/leaderboard', auth, async (req, res) => {
- try {
- const { period, limit } = req.query;
- const leaderboard = await gamificationService.getGlobalLeaderboard({
- period: period || 'all',
- limit: parseInt(limit) || 50
- });
-
- res.json({
- success: true,
- data: leaderboard
- });
- } catch (error) {
- console.error('[Gamification] Get leaderboard error:', error);
- res.status(500).json({ error: error.message });
+router.delete('/challenges/:id', auth, async (req, res) => {
+ try {
+ const challenge = await Challenge.findById(req.params.id);
+
+ if (!challenge) {
+ return res.status(404).json({ error: 'Challenge not found' });
}
+
+ if (challenge.creator.toString() !== req.user._id.toString()) {
+ return res.status(403).json({ error: 'Only the creator can delete this challenge' });
+ }
+
+ if (challenge.status === 'active') {
+ // Mark as cancelled instead of deleting
+ challenge.status = 'cancelled';
+ await challenge.save();
+
+ return res.json({
+ success: true,
+ message: 'Challenge cancelled'
+ });
+ }
+
+ await Challenge.findByIdAndDelete(req.params.id);
+
+ res.json({
+ success: true,
+ message: 'Challenge deleted'
+ });
+ } catch (error) {
+ res.status(500).json({ error: error.message });
+ }
});
/**
- * @route GET /api/gamification/leaderboard/rank
- * @desc Get user's rank on leaderboard
+ * @route POST /api/gamification/challenges/:id/invite
+ * @desc Invite users to a challenge
* @access Private
*/
-router.get('/leaderboard/rank', auth, async (req, res) => {
- try {
- const { period } = req.query;
- const leaderboard = await gamificationService.getGlobalLeaderboard({
- period: period || 'all',
- limit: 1000
- });
-
- const userRank = leaderboard.findIndex(entry =>
- entry.user.id.toString() === req.user._id.toString()
- );
-
- res.json({
- success: true,
- data: {
- rank: userRank >= 0 ? userRank + 1 : null,
- totalParticipants: leaderboard.length
- }
+router.post('/challenges/:id/invite', auth, async (req, res) => {
+ try {
+ const { userIds } = req.body;
+
+ if (!userIds || !Array.isArray(userIds) || userIds.length === 0) {
+ return res.status(400).json({ error: 'User IDs array is required' });
+ }
+
+ const challenge = await Challenge.findById(req.params.id);
+
+ if (!challenge) {
+ return res.status(404).json({ error: 'Challenge not found' });
+ }
+
+ if (challenge.creator.toString() !== req.user._id.toString()) {
+ return res.status(403).json({ error: 'Only the creator can invite users' });
+ }
+
+ // Add invited users
+ const newInvites = userIds.filter(id =>
+ !challenge.invitedUsers.includes(id) &&
+ !challenge.isParticipant(id)
+ );
+
+ challenge.invitedUsers.push(...newInvites);
+ await challenge.save();
+
+ // Send notifications to invited users
+ if (global.io) {
+ for (const userId of newInvites) {
+ global.io.to(`user_${userId}`).emit('challenge_invitation', {
+ challengeId: challenge._id,
+ challengeTitle: challenge.title,
+ invitedBy: req.user.name
});
- } catch (error) {
- console.error('[Gamification] Get rank error:', error);
- res.status(500).json({ error: error.message });
+ }
}
+
+ res.json({
+ success: true,
+ message: `Invited ${newInvites.length} users`,
+ data: { invitedCount: newInvites.length }
+ });
+ } catch (error) {
+ res.status(500).json({ error: error.message });
+ }
});
-// ========== ADMIN ROUTES (for seeding) ==========
+// ==================== STATS ROUTES ====================
/**
- * @route POST /api/gamification/admin/seed
- * @desc Seed default achievements and challenges (admin only)
+ * @route GET /api/gamification/stats
+ * @desc Get detailed gamification stats
* @access Private
*/
-router.post('/admin/seed', auth, async (req, res) => {
- try {
- await gamificationService.seedAchievements();
- await gamificationService.createSystemChallenges();
+router.get('/stats', auth, async (req, res) => {
+ try {
+ const profile = await gamificationService.getOrCreateProfile(req.user._id);
+ const UserGamification = require('../models/UserGamification');
+
+ // Get global rank
+ const globalRank = await UserGamification.countDocuments({
+ totalPoints: { $gt: profile.totalPoints }
+ }) + 1;
+
+ const totalUsers = await UserGamification.countDocuments();
+
+ // Get challenge stats
+ const challengeStats = await Challenge.aggregate([
+ {
+ $match: {
+ 'participants.user': profile.user
+ }
+ },
+ {
+ $group: {
+ _id: '$status',
+ count: { $sum: 1 }
+ }
+ }
+ ]);
+
+ const challenges = {
+ active: 0,
+ completed: 0,
+ upcoming: 0
+ };
+
+ challengeStats.forEach(stat => {
+ if (stat._id === 'active') challenges.active = stat.count;
+ if (stat._id === 'completed') challenges.completed = stat.count;
+ if (stat._id === 'upcoming') challenges.upcoming = stat.count;
+ });
+
+ res.json({
+ success: true,
+ data: {
+ points: {
+ total: profile.totalPoints,
+ weekly: profile.weeklyPoints,
+ monthly: profile.monthlyPoints
+ },
+ level: {
+ current: profile.level,
+ rank: profile.rank,
+ experience: profile.experience,
+ toNextLevel: profile.experienceToNextLevel
+ },
+ ranking: {
+ global: globalRank,
+ totalUsers,
+ percentile: Math.round((1 - (globalRank / totalUsers)) * 100)
+ },
+ achievements: {
+ earned: profile.earnedAchievements.length,
+ inProgress: profile.achievementProgress.filter(ap =>
+ ap.currentValue > 0 && ap.currentValue < ap.targetValue
+ ).length
+ },
+ challenges,
+ streaks: profile.streaks.map(s => ({
+ type: s.type,
+ current: s.currentStreak,
+ longest: s.longestStreak
+ })),
+ stats: profile.stats
+ }
+ });
+ } catch (error) {
+ res.status(500).json({ error: error.message });
+ }
+});
- res.json({
- success: true,
- message: 'Default achievements and challenges seeded'
- });
- } catch (error) {
- console.error('[Gamification] Seed error:', error);
- res.status(500).json({ error: error.message });
- }
+/**
+ * @route POST /api/gamification/init
+ * @desc Initialize default achievements (admin only)
+ * @access Private
+ */
+router.post('/init', auth, async (req, res) => {
+ try {
+ await gamificationService.initializeDefaultAchievements();
+
+ res.json({
+ success: true,
+ message: 'Default achievements initialized'
+ });
+ } catch (error) {
+ res.status(500).json({ error: error.message });
+ }
});
module.exports = router;
diff --git a/server.js b/server.js
index 5bcd194..da19181 100644
--- a/server.js
+++ b/server.js
@@ -243,10 +243,8 @@ app.use('/api/currency', require('./routes/currency'));
app.use('/api/groups', require('./routes/groups'));
app.use('/api/splits', require('./routes/splits'));
app.use('/api/workspaces', require('./routes/workspaces'));
-app.use('/api/tax', require('./routes/tax'));
-app.use('/api/reports', require('./routes/reports'));
-app.use('/api/recurring', require('./routes/recurring'));
-app.use('/api/subscriptions', require('./routes/subscriptions'));
+app.use('/api/approvals', require('./routes/approvals'));
+app.use('/api/investments', require('./routes/investments'));
app.use('/api/gamification', require('./routes/gamification'));
// Root route to serve the UI
diff --git a/services/gamificationService.js b/services/gamificationService.js
index 3d9289b..dab4cdc 100644
--- a/services/gamificationService.js
+++ b/services/gamificationService.js
@@ -1,248 +1,42 @@
const Challenge = require('../models/Challenge');
const Achievement = require('../models/Achievement');
-const UserAchievement = require('../models/UserAchievement');
-const ChallengeParticipant = require('../models/ChallengeParticipant');
const UserGamification = require('../models/UserGamification');
const Expense = require('../models/Expense');
-const Budget = require('../models/Budget');
const Goal = require('../models/Goal');
-const notificationService = require('./notificationService');
-
-// Default achievements to seed
-const DEFAULT_ACHIEVEMENTS = [
- {
- code: 'BUDGET_MASTER',
- name: 'Budget Master',
- description: 'Stay under budget for 3 consecutive months',
- category: 'budgeting',
- criteria: { type: 'budget_streak', targetValue: 90 }, // 90 days
- badge: '๐',
- color: '#FFD700',
- points: 500,
- rarity: 'epic'
- },
- {
- code: 'SAVINGS_STREAK',
- name: 'Savings Streak',
- description: 'Save consistently for 30 days in a row',
- category: 'savings',
- criteria: { type: 'savings_streak', targetValue: 30 },
- badge: '๐',
- color: '#00D4FF',
- points: 300,
- rarity: 'rare'
- },
- {
- code: 'ANALYTICS_PRO',
- name: 'Analytics Pro',
- description: 'Check your dashboard 7 days in a row',
- category: 'analytics',
- criteria: { type: 'dashboard_streak', targetValue: 7 },
- badge: '๐',
- color: '#9B59B6',
- points: 100,
- rarity: 'common'
- },
- {
- code: 'GOAL_CRUSHER',
- name: 'Goal Crusher',
- description: 'Achieve 5 financial goals',
- category: 'goals',
- criteria: { type: 'goals_completed', targetValue: 5 },
- badge: '๐ฏ',
- color: '#E74C3C',
- points: 400,
- rarity: 'rare'
- },
- {
- code: 'FIRST_EXPENSE',
- name: 'First Steps',
- description: 'Log your first expense',
- category: 'milestones',
- criteria: { type: 'expense_logged', targetValue: 1 },
- badge: '๐',
- color: '#3498DB',
- points: 10,
- rarity: 'common'
- },
- {
- code: 'EXPENSE_CENTURION',
- name: 'Expense Centurion',
- description: 'Log 100 expenses',
- category: 'milestones',
- criteria: { type: 'expense_logged', targetValue: 100 },
- badge: '๐ฏ',
- color: '#2ECC71',
- points: 200,
- rarity: 'uncommon'
- },
- {
- code: 'CHALLENGE_CHAMPION',
- name: 'Challenge Champion',
- description: 'Win 3 challenges',
- category: 'challenges',
- criteria: { type: 'challenges_won', targetValue: 3 },
- badge: '๐
',
- color: '#F39C12',
- points: 350,
- rarity: 'rare'
- },
- {
- code: 'FIRST_GOAL',
- name: 'Goal Setter',
- description: 'Complete your first financial goal',
- category: 'goals',
- criteria: { type: 'goals_completed', targetValue: 1 },
- badge: '๐๏ธ',
- color: '#1ABC9C',
- points: 50,
- rarity: 'common'
- },
- {
- code: 'WEEKLY_WARRIOR',
- name: 'Weekly Warrior',
- description: 'Log expenses every day for a week',
- category: 'streaks',
- criteria: { type: 'dashboard_streak', targetValue: 7 },
- badge: 'โ๏ธ',
- color: '#8E44AD',
- points: 75,
- rarity: 'common'
- },
- {
- code: 'MONTHLY_MASTER',
- name: 'Monthly Master',
- description: 'Track expenses every day for a month',
- category: 'streaks',
- criteria: { type: 'dashboard_streak', targetValue: 30 },
- badge: '๐',
- color: '#E67E22',
- points: 250,
- rarity: 'uncommon'
- },
- {
- code: 'SAVER_SUPREME',
- name: 'Saver Supreme',
- description: 'Save a total of โน50,000',
- category: 'savings',
- criteria: { type: 'total_saved', targetValue: 50000 },
- badge: '๐ฐ',
- color: '#27AE60',
- points: 600,
- rarity: 'epic'
- },
- {
- code: 'FOOD_FRUGAL',
- name: 'Food Frugal',
- description: 'Stay under food budget for 3 months',
- category: 'budgeting',
- criteria: { type: 'category_mastered', targetValue: 90, category: 'food' },
- badge: '๐ฝ๏ธ',
- color: '#D35400',
- points: 200,
- rarity: 'uncommon'
- }
-];
-
-// Default system challenges
-const DEFAULT_CHALLENGES = [
- {
- title: 'No Spend Weekend',
- description: 'Avoid all non-essential spending for an entire weekend',
- type: 'no_spend',
- config: { targetAmount: 0, streakDays: 2 },
- difficulty: 'easy',
- icon: '๐ซ',
- rewards: { points: 50 },
- tags: ['weekend', 'no-spend', 'beginner']
- },
- {
- title: 'Coffee Shop Savings',
- description: 'Reduce your coffee shop expenses by 50% this month',
- type: 'reduction',
- config: { targetPercentage: 50, category: 'food', comparisonPeriod: 30 },
- difficulty: 'medium',
- icon: 'โ',
- rewards: { points: 150 },
- tags: ['coffee', 'savings', 'food']
- },
- {
- title: 'Meal Prep Month',
- description: 'Reduce food delivery expenses by 75% this month',
- type: 'reduction',
- config: { targetPercentage: 75, category: 'food', comparisonPeriod: 30 },
- difficulty: 'hard',
- icon: '๐ฅ',
- rewards: { points: 300 },
- tags: ['meal-prep', 'food', 'health']
- },
- {
- title: '7-Day Savings Sprint',
- description: 'Save at least โน500 every day for 7 days',
- type: 'savings_target',
- config: { targetAmount: 3500, streakDays: 7 },
- difficulty: 'medium',
- icon: '๐',
- rewards: { points: 200 },
- tags: ['savings', 'sprint', 'weekly']
- },
- {
- title: 'Budget Guardian',
- description: 'Stay under your total budget for 30 days',
- type: 'budget_under',
- config: { streakDays: 30 },
- difficulty: 'hard',
- icon: '๐ก๏ธ',
- rewards: { points: 400 },
- tags: ['budget', 'monthly', 'discipline']
- }
-];
+const mongoose = require('mongoose');
class GamificationService {
/**
- * Initialize gamification profile for a user
+ * Initialize or get user gamification profile
*/
- async initUserProfile(userId) {
+ async getOrCreateProfile(userId) {
let profile = await UserGamification.findOne({ user: userId });
if (!profile) {
profile = new UserGamification({ user: userId });
await profile.save();
+
+ // Check for first-time achievements
+ await this.checkAchievement(userId, 'first_login');
}
return profile;
}
/**
- * Get user's gamification profile
- */
- async getUserProfile(userId) {
- let profile = await UserGamification.findOne({ user: userId })
- .populate('featuredBadges')
- .populate('badges.achievement');
-
- if (!profile) {
- profile = await this.initUserProfile(userId);
- }
-
- return profile;
- }
-
- /**
- * Add points to user
+ * Award points to user
*/
- async addPoints(userId, points, reason) {
- const profile = await this.getUserProfile(userId);
- profile.addPoints(points, reason);
- profile.lastActivityAt = new Date();
+ async awardPoints(userId, points, reason) {
+ const profile = await this.getOrCreateProfile(userId);
+ profile.addPoints(points);
await profile.save();
- // Emit real-time update
+ // Emit notification if socket available
if (global.io) {
global.io.to(`user_${userId}`).emit('points_earned', {
points,
reason,
- totalPoints: profile.points.total,
+ totalPoints: profile.totalPoints,
level: profile.level
});
}
@@ -253,601 +47,1014 @@ class GamificationService {
/**
* Update user streak
*/
- async updateStreak(userId, streakType, achieved) {
- const profile = await this.getUserProfile(userId);
- profile.updateStreak(streakType, achieved);
+ async updateStreak(userId, type) {
+ const profile = await this.getOrCreateProfile(userId);
+ const streak = profile.updateStreak(type, true);
await profile.save();
- // Check for streak-based achievements
- await this.checkAchievements(userId);
-
- return profile.streaks[streakType];
- }
-
- /**
- * Seed default achievements
- */
- async seedAchievements() {
- for (const achievement of DEFAULT_ACHIEVEMENTS) {
- const exists = await Achievement.findOne({ code: achievement.code });
- if (!exists) {
- await Achievement.create(achievement);
- console.log(`[Gamification] Created achievement: ${achievement.code}`);
- }
- }
- }
-
- /**
- * Get all achievements
- */
- async getAchievements(options = {}) {
- const { category, rarity, includeSecret = false } = options;
-
- const query = { isActive: true };
- if (category) query.category = category;
- if (rarity) query.rarity = rarity;
- if (!includeSecret) query.isSecret = false;
+ // Check streak-based achievements
+ await this.checkStreakAchievements(userId, type, streak.currentStreak);
- return await Achievement.find(query).sort({ displayOrder: 1, rarity: 1 });
+ return streak;
}
/**
- * Get user's achievement progress
+ * Check and award achievements
*/
- async getUserAchievements(userId, options = {}) {
- const { status, category } = options;
-
- const query = { user: userId };
- if (status) query.status = status;
+ async checkAchievement(userId, type, value = 1, category = null) {
+ const profile = await this.getOrCreateProfile(userId);
- let achievements = await UserAchievement.find(query)
- .populate('achievement')
- .sort({ completedAt: -1, updatedAt: -1 });
+ // Find matching achievements
+ const query = {
+ 'requirement.type': type,
+ isActive: true
+ };
if (category) {
- achievements = achievements.filter(ua => ua.achievement.category === category);
+ query['requirement.category'] = category;
}
- return achievements;
- }
-
- /**
- * Check and award achievements for a user
- */
- async checkAchievements(userId) {
- const profile = await this.getUserProfile(userId);
- const achievements = await Achievement.find({ isActive: true });
- const earnedAchievements = [];
+ const achievements = await Achievement.find(query);
+ const newlyEarned = [];
for (const achievement of achievements) {
- // Check if already completed
- let userAchievement = await UserAchievement.findOne({
- user: userId,
- achievement: achievement._id
- });
+ // Skip if already earned
+ if (profile.hasAchievement(achievement._id)) continue;
- if (userAchievement?.status === 'completed') continue;
+ // Update or create progress
+ let progress = profile.getProgress(achievement.code);
- // Calculate progress
- const progress = await this.calculateAchievementProgress(userId, achievement, profile);
-
- if (!userAchievement) {
- userAchievement = new UserAchievement({
- user: userId,
- achievement: achievement._id,
- progress: { current: progress, target: achievement.criteria.targetValue }
+ if (!progress) {
+ profile.achievementProgress.push({
+ achievementCode: achievement.code,
+ currentValue: 0,
+ targetValue: achievement.requirement.value
});
- } else {
- userAchievement.progress.current = progress;
+ progress = profile.achievementProgress[profile.achievementProgress.length - 1];
}
- // Check if completed
- if (progress >= achievement.criteria.targetValue && userAchievement.status !== 'completed') {
- userAchievement.status = 'completed';
- userAchievement.completedAt = new Date();
-
- // Award points
- await this.addPoints(userId, achievement.points, `Achievement: ${achievement.name}`);
-
- // Add badge to profile
- profile.badges.push({
+ // Update progress
+ progress.currentValue = Math.max(progress.currentValue, value);
+ progress.lastUpdated = new Date();
+
+ // Check if achievement is earned
+ if (progress.currentValue >= achievement.requirement.value) {
+ profile.earnedAchievements.push({
achievement: achievement._id,
- earnedAt: new Date()
+ earnedAt: new Date(),
+ progress: 100
});
- profile.stats.achievementsEarned++;
- await profile.save();
- earnedAchievements.push(achievement);
+ // Award points
+ profile.addPoints(achievement.points);
- // Send notification
- await notificationService.sendNotification(userId, {
- title: 'Achievement Unlocked!',
- message: `Congratulations! You've earned the "${achievement.name}" badge! ${achievement.badge}`,
- type: 'achievement_earned',
- priority: 'high',
- data: {
- achievementId: achievement._id,
- badge: achievement.badge,
- points: achievement.points
- }
+ newlyEarned.push({
+ achievement,
+ points: achievement.points
});
}
-
- await userAchievement.save();
}
- return earnedAchievements;
+ await profile.save();
+
+ // Send notifications for new achievements
+ if (newlyEarned.length > 0 && global.io) {
+ for (const earned of newlyEarned) {
+ global.io.to(`user_${userId}`).emit('achievement_unlocked', {
+ name: earned.achievement.name,
+ description: earned.achievement.description,
+ icon: earned.achievement.icon,
+ points: earned.points,
+ tier: earned.achievement.tier
+ });
+ }
+ }
+
+ return newlyEarned;
}
/**
- * Calculate progress for an achievement
+ * Check streak-based achievements
*/
- async calculateAchievementProgress(userId, achievement, profile) {
- const { type, targetValue, category } = achievement.criteria;
+ async checkStreakAchievements(userId, streakType, currentStreak) {
+ const typeMapping = {
+ 'login': 'login_streak',
+ 'expense_tracking': 'expense_tracking',
+ 'budget_adherence': 'budget_streak',
+ 'no_spend': 'no_spend_days'
+ };
+
+ const achievementType = typeMapping[streakType];
+ if (achievementType) {
+ await this.checkAchievement(userId, achievementType, currentStreak);
+ }
+ }
+
+ /**
+ * Get leaderboard
+ */
+ async getLeaderboard(type = 'all_time', limit = 50, userId = null) {
+ let sortField;
switch (type) {
- case 'budget_streak':
- return profile.streaks.budget.current;
-
- case 'savings_streak':
- return profile.streaks.savings.current;
-
- case 'dashboard_streak':
- return profile.streaks.login.current;
-
- case 'goals_completed':
- return profile.stats.goalsCompleted;
-
- case 'challenges_won':
- return profile.stats.challengesWon;
-
- case 'total_saved':
- return profile.stats.totalSaved;
-
- case 'expense_logged':
- const expenseCount = await Expense.countDocuments({ user: userId });
- return expenseCount;
-
- case 'category_mastered':
- // This would need more complex logic based on budget history
- return profile.stats.monthsUnderBudget;
-
+ case 'weekly':
+ sortField = 'weeklyPoints';
+ break;
+ case 'monthly':
+ sortField = 'monthlyPoints';
+ break;
default:
- return 0;
+ sortField = 'totalPoints';
}
+
+ const leaderboard = await UserGamification.find({
+ 'privacySettings.showOnLeaderboard': true
+ })
+ .sort({ [sortField]: -1 })
+ .limit(limit)
+ .populate('user', 'name email')
+ .lean();
+
+ // Add rank positions
+ const result = leaderboard.map((entry, index) => ({
+ rank: index + 1,
+ userId: entry.user._id,
+ name: entry.user.name,
+ points: entry[sortField],
+ level: entry.level,
+ rankTitle: entry.rank,
+ achievementCount: entry.earnedAchievements.length
+ }));
+
+ // If userId provided, add user's position if not in top
+ if (userId) {
+ const userInList = result.find(r => r.userId.toString() === userId.toString());
+
+ if (!userInList) {
+ const userProfile = await UserGamification.findOne({ user: userId })
+ .populate('user', 'name');
+
+ if (userProfile) {
+ const userRank = await UserGamification.countDocuments({
+ 'privacySettings.showOnLeaderboard': true,
+ [sortField]: { $gt: userProfile[sortField] }
+ }) + 1;
+
+ result.push({
+ rank: userRank,
+ userId: userProfile.user._id,
+ name: userProfile.user.name,
+ points: userProfile[sortField],
+ level: userProfile.level,
+ rankTitle: userProfile.rank,
+ achievementCount: userProfile.earnedAchievements.length,
+ isCurrentUser: true
+ });
+ }
+ } else {
+ userInList.isCurrentUser = true;
+ }
+ }
+
+ return result;
}
- // ========== CHALLENGE METHODS ==========
-
/**
- * Create a new challenge
+ * Create a challenge
*/
- async createChallenge(userId, data) {
+ async createChallenge(creatorId, challengeData) {
const challenge = new Challenge({
- ...data,
- creator: userId,
- startDate: new Date(data.startDate),
- endDate: new Date(data.endDate)
+ ...challengeData,
+ creator: creatorId,
+ participants: [{
+ user: creatorId,
+ joinedAt: new Date(),
+ status: 'active'
+ }]
});
- await challenge.save();
-
- // If starting now or in past, set to active
- if (new Date(challenge.startDate) <= new Date()) {
+ // Update challenge status based on dates
+ const now = new Date();
+ if (challenge.startDate <= now && challenge.endDate > now) {
challenge.status = 'active';
- await challenge.save();
+ } else if (challenge.startDate > now) {
+ challenge.status = 'upcoming';
}
+ await challenge.save();
+
+ // Award points for creating challenge
+ await this.awardPoints(creatorId, 10, 'Created a challenge');
+
+ // Update user stats
+ const profile = await this.getOrCreateProfile(creatorId);
+ profile.challengesJoined++;
+ await profile.save();
+
return challenge;
}
- /**
- * Get available challenges
- */
- async getChallenges(options = {}) {
- const { status, scope, type, userId } = options;
-
- const query = {};
- if (status) query.status = status;
- if (type) query.type = type;
-
- // Scope filtering
- if (scope) {
- query.scope = scope;
- } else if (userId) {
- // Show personal (own), public, and workspace challenges
- query.$or = [
- { scope: 'public' },
- { scope: 'personal', creator: userId },
- { isSystemChallenge: true }
- ];
- }
-
- return await Challenge.find(query)
- .populate('creator', 'name')
- .sort({ startDate: -1 });
- }
-
- /**
- * Get challenge by ID
- */
- async getChallengeById(challengeId) {
- return await Challenge.findById(challengeId).populate('creator', 'name');
- }
-
/**
* Join a challenge
*/
async joinChallenge(userId, challengeId) {
const challenge = await Challenge.findById(challengeId);
+
if (!challenge) {
throw new Error('Challenge not found');
}
- if (challenge.status !== 'active' && challenge.status !== 'upcoming') {
- throw new Error('Challenge is not available to join');
+ if (challenge.status === 'completed' || challenge.status === 'cancelled') {
+ throw new Error('This challenge is no longer active');
}
- // Check if already joined
- const existing = await ChallengeParticipant.findOne({
- challenge: challengeId,
- user: userId
- });
-
- if (existing) {
- throw new Error('Already joined this challenge');
+ if (challenge.isParticipant(userId)) {
+ throw new Error('Already participating in this challenge');
}
- // Calculate target based on challenge type
- let target = 0;
- switch (challenge.type) {
- case 'no_spend':
- target = challenge.config.streakDays || 1;
- break;
- case 'savings_target':
- target = challenge.config.targetAmount || 0;
- break;
- case 'budget_under':
- target = challenge.config.streakDays || 30;
- break;
- case 'reduction':
- target = 100; // Progress percentage
- break;
- case 'streak':
- target = challenge.config.streakDays || 7;
- break;
- default:
- target = challenge.config.targetAmount || 100;
+ if (challenge.maxParticipants > 0 && challenge.participants.length >= challenge.maxParticipants) {
+ throw new Error('Challenge is full');
}
- const participant = new ChallengeParticipant({
- challenge: challengeId,
+ challenge.participants.push({
user: userId,
- status: challenge.status === 'active' ? 'active' : 'joined',
- progress: { current: 0, target }
+ joinedAt: new Date(),
+ status: 'active'
});
- await participant.save();
+ await challenge.save();
// Update user stats
- const profile = await this.getUserProfile(userId);
- profile.stats.challengesJoined++;
+ const profile = await this.getOrCreateProfile(userId);
+ profile.challengesJoined++;
await profile.save();
- // Send notification
- await notificationService.sendNotification(userId, {
- title: 'Challenge Joined!',
- message: `You've joined the "${challenge.title}" challenge! ${challenge.icon}`,
- type: 'challenge_joined',
- priority: 'medium',
- data: { challengeId }
- });
+ // Award points for joining
+ await this.awardPoints(userId, 5, 'Joined a challenge');
- return participant;
+ return challenge;
}
/**
- * Leave a challenge
+ * Update challenge progress for a user
*/
- async leaveChallenge(userId, challengeId) {
- const participant = await ChallengeParticipant.findOne({
- challenge: challengeId,
- user: userId
- });
+ async updateChallengeProgress(userId, challengeId, progressData) {
+ const challenge = await Challenge.findById(challengeId);
+
+ if (!challenge) {
+ throw new Error('Challenge not found');
+ }
+
+ const participant = challenge.getParticipant(userId);
if (!participant) {
- throw new Error('Not participating in this challenge');
+ throw new Error('Not a participant of this challenge');
}
- participant.status = 'withdrawn';
- await participant.save();
+ // Update progress based on challenge type
+ participant.progress = progressData.progress || participant.progress;
+ participant.currentStreak = progressData.currentStreak || participant.currentStreak;
+ participant.savedAmount = progressData.savedAmount || participant.savedAmount;
+
+ if (progressData.completedDay) {
+ participant.completedDays.push({
+ date: new Date(),
+ value: progressData.dayValue || 0
+ });
+ }
+
+ if (participant.currentStreak > participant.bestStreak) {
+ participant.bestStreak = participant.currentStreak;
+ }
+
+ // Check if challenge completed
+ if (participant.progress >= 100 && participant.status === 'active') {
+ participant.status = 'completed';
+ participant.completedAt = new Date();
+
+ // Award completion points
+ await this.completeChallengeForUser(userId, challenge);
+ }
+
+ await challenge.save();
- return participant;
+ return challenge;
}
/**
- * Update challenge progress for a user
+ * Complete challenge for user
*/
- async updateChallengeProgress(userId, challengeId, progressUpdate) {
- const participant = await ChallengeParticipant.findOne({
- challenge: challengeId,
- user: userId,
- status: 'active'
- });
+ async completeChallengeForUser(userId, challenge) {
+ const profile = await this.getOrCreateProfile(userId);
- if (!participant) {
- throw new Error('Not actively participating in this challenge');
- }
+ // Award points
+ await this.awardPoints(userId, challenge.rewardPoints, `Completed challenge: ${challenge.title}`);
- const challenge = await Challenge.findById(challengeId);
+ // Update stats
+ profile.challengesCompleted++;
- // Update progress
- if (progressUpdate.increment) {
- participant.progress.current += progressUpdate.increment;
- } else if (progressUpdate.set !== undefined) {
- participant.progress.current = progressUpdate.set;
+ const participant = challenge.getParticipant(userId);
+ if (participant && participant.savedAmount) {
+ profile.totalSavedFromChallenges += participant.savedAmount;
}
- // Update daily progress
- const today = new Date();
- today.setHours(0, 0, 0, 0);
+ await profile.save();
+
+ // Check challenge-related achievements
+ await this.checkAchievement(userId, 'challenge_wins', profile.challengesCompleted);
- const todayProgress = participant.progress.dailyProgress.find(
- dp => new Date(dp.date).getTime() === today.getTime()
- );
+ // Award badge if challenge has one
+ if (challenge.rewardBadge) {
+ // Badge logic would go here
+ }
- if (todayProgress) {
- todayProgress.value = progressUpdate.dailyValue || participant.progress.current;
- todayProgress.achieved = progressUpdate.achieved || false;
- } else {
- participant.progress.dailyProgress.push({
- date: today,
- value: progressUpdate.dailyValue || participant.progress.current,
- achieved: progressUpdate.achieved || false
+ // Emit notification
+ if (global.io) {
+ global.io.to(`user_${userId}`).emit('challenge_completed', {
+ challengeTitle: challenge.title,
+ points: challenge.rewardPoints
});
}
+ }
+
+ /**
+ * Calculate challenge progress based on expenses
+ */
+ async calculateChallengeProgress(userId, challengeId) {
+ const challenge = await Challenge.findById(challengeId);
- // Update streak
- if (progressUpdate.achieved !== undefined) {
- participant.updateStreak(progressUpdate.achieved);
+ if (!challenge) {
+ throw new Error('Challenge not found');
}
- // Check if completed
- if (participant.hasCompleted() && participant.status !== 'completed') {
- participant.status = 'completed';
- participant.completedAt = new Date();
- participant.pointsEarned = challenge.rewards.points;
+ const participant = challenge.getParticipant(userId);
+
+ if (!participant) {
+ throw new Error('Not a participant');
+ }
+
+ let progress = 0;
+ let savedAmount = 0;
+
+ const startDate = new Date(Math.max(challenge.startDate, participant.joinedAt));
+ const endDate = new Date(Math.min(challenge.endDate, new Date()));
+
+ switch (challenge.type) {
+ case 'no_spend': {
+ // Count days with no spending
+ const expenses = await Expense.aggregate([
+ {
+ $match: {
+ user: new mongoose.Types.ObjectId(userId),
+ type: 'expense',
+ date: { $gte: startDate, $lte: endDate }
+ }
+ },
+ {
+ $group: {
+ _id: {
+ $dateToString: { format: '%Y-%m-%d', date: '$date' }
+ },
+ total: { $sum: '$amount' }
+ }
+ }
+ ]);
+
+ const daysWithExpenses = new Set(expenses.map(e => e._id));
+ const totalDays = Math.ceil((endDate - startDate) / (1000 * 60 * 60 * 24)) + 1;
+ const noSpendDays = totalDays - daysWithExpenses.size;
+
+ progress = Math.min(100, (noSpendDays / challenge.targetValue) * 100);
+ break;
+ }
- // Award points
- await this.addPoints(userId, challenge.rewards.points, `Challenge completed: ${challenge.title}`);
+ case 'category_reduction': {
+ // Compare spending in category to previous period
+ const periodLength = Math.ceil((challenge.endDate - challenge.startDate) / (1000 * 60 * 60 * 24));
+ const previousStart = new Date(challenge.startDate);
+ previousStart.setDate(previousStart.getDate() - periodLength);
+
+ const categoryQuery = challenge.category === 'all'
+ ? {}
+ : { category: challenge.category };
+
+ const [currentPeriod, previousPeriod] = await Promise.all([
+ Expense.aggregate([
+ {
+ $match: {
+ user: new mongoose.Types.ObjectId(userId),
+ type: 'expense',
+ date: { $gte: startDate, $lte: endDate },
+ ...categoryQuery
+ }
+ },
+ { $group: { _id: null, total: { $sum: '$amount' } } }
+ ]),
+ Expense.aggregate([
+ {
+ $match: {
+ user: new mongoose.Types.ObjectId(userId),
+ type: 'expense',
+ date: { $gte: previousStart, $lt: challenge.startDate },
+ ...categoryQuery
+ }
+ },
+ { $group: { _id: null, total: { $sum: '$amount' } } }
+ ])
+ ]);
+
+ const currentTotal = currentPeriod[0]?.total || 0;
+ const previousTotal = previousPeriod[0]?.total || 1;
+
+ const reduction = ((previousTotal - currentTotal) / previousTotal) * 100;
+ savedAmount = Math.max(0, previousTotal - currentTotal);
+ progress = Math.min(100, (reduction / challenge.targetValue) * 100);
+ break;
+ }
- // Update user stats
- const profile = await this.getUserProfile(userId);
- profile.stats.challengesCompleted++;
- await profile.save();
+ case 'savings_target': {
+ // Calculate net savings
+ const stats = await Expense.aggregate([
+ {
+ $match: {
+ user: new mongoose.Types.ObjectId(userId),
+ date: { $gte: startDate, $lte: endDate }
+ }
+ },
+ {
+ $group: {
+ _id: '$type',
+ total: { $sum: '$amount' }
+ }
+ }
+ ]);
+
+ let income = 0, expenses = 0;
+ stats.forEach(s => {
+ if (s._id === 'income') income = s.total;
+ if (s._id === 'expense') expenses = s.total;
+ });
+
+ savedAmount = Math.max(0, income - expenses);
+ progress = Math.min(100, (savedAmount / challenge.targetValue) * 100);
+ break;
+ }
- // Send notification
- await notificationService.sendNotification(userId, {
- title: 'Challenge Completed!',
- message: `Congratulations! You've completed the "${challenge.title}" challenge! ๐`,
- type: 'challenge_completed',
- priority: 'high',
- data: { challengeId, points: challenge.rewards.points }
- });
+ case 'budget_adherence': {
+ // Check if staying under budget
+ // This would integrate with the budget system
+ const Budget = require('../models/Budget');
+ const budgets = await Budget.find({
+ user: userId,
+ startDate: { $lte: endDate },
+ endDate: { $gte: startDate }
+ });
+
+ if (budgets.length > 0) {
+ let underBudgetDays = 0;
+ const totalDays = Math.ceil((endDate - startDate) / (1000 * 60 * 60 * 24));
+
+ // Simplified: check if overall spending is under budget
+ for (const budget of budgets) {
+ const spent = await Expense.aggregate([
+ {
+ $match: {
+ user: new mongoose.Types.ObjectId(userId),
+ type: 'expense',
+ date: { $gte: budget.startDate, $lte: budget.endDate },
+ category: budget.category
+ }
+ },
+ { $group: { _id: null, total: { $sum: '$amount' } } }
+ ]);
+
+ if (!spent[0] || spent[0].total <= budget.amount) {
+ underBudgetDays += totalDays;
+ }
+ }
+
+ progress = Math.min(100, (underBudgetDays / challenge.targetValue) * 100);
+ }
+ break;
+ }
- // Check achievements
- await this.checkAchievements(userId);
+ case 'streak': {
+ progress = Math.min(100, (participant.currentStreak / challenge.targetValue) * 100);
+ break;
+ }
+
+ default:
+ // Custom challenge - use manual progress updates
+ break;
}
- await participant.save();
- return participant;
+ // Update participant
+ await this.updateChallengeProgress(userId, challengeId, {
+ progress: Math.round(progress),
+ savedAmount
+ });
+
+ return { progress: Math.round(progress), savedAmount };
}
/**
- * Get user's challenge participations
+ * Get active challenges for user
*/
- async getUserChallenges(userId, options = {}) {
- const { status } = options;
+ async getUserChallenges(userId, status = null) {
+ const query = {
+ 'participants.user': userId
+ };
- const query = { user: userId };
if (status) {
- if (Array.isArray(status)) {
- query.status = { $in: status };
- } else {
- query.status = status;
- }
+ query.status = status;
}
- return await ChallengeParticipant.find(query)
- .populate('challenge')
- .sort({ updatedAt: -1 });
+ const challenges = await Challenge.find(query)
+ .populate('creator', 'name')
+ .sort({ startDate: -1 });
+
+ return challenges.map(challenge => {
+ const participant = challenge.getParticipant(userId);
+ return {
+ ...challenge.toObject(),
+ userProgress: participant?.progress || 0,
+ userStatus: participant?.status || 'active',
+ userStreak: participant?.currentStreak || 0,
+ daysRemaining: challenge.getDaysRemaining()
+ };
+ });
}
/**
- * Get leaderboard for a challenge
+ * Get available public challenges
*/
- async getChallengeLeaderboard(challengeId, limit = 10) {
- const participants = await ChallengeParticipant.find({
- challenge: challengeId,
- showProgress: true,
- status: { $in: ['active', 'completed'] }
- })
- .populate('user', 'name')
- .sort({ 'progress.current': -1, completedAt: 1 })
- .limit(limit);
+ async getPublicChallenges(userId, filters = {}) {
+ const query = {
+ isPublic: true,
+ status: { $in: ['upcoming', 'active'] }
+ };
- return participants.map((p, index) => ({
- rank: index + 1,
- user: {
- id: p.user._id,
- name: p.user.name
- },
- progress: p.progressPercentage,
- streak: p.streak.current,
- completed: p.status === 'completed'
+ if (filters.type) {
+ query.type = filters.type;
+ }
+
+ if (filters.difficulty) {
+ query.difficulty = filters.difficulty;
+ }
+
+ const challenges = await Challenge.find(query)
+ .populate('creator', 'name')
+ .sort({ startDate: 1 })
+ .limit(filters.limit || 20);
+
+ return challenges.map(challenge => ({
+ ...challenge.toObject(),
+ isJoined: challenge.isParticipant(userId),
+ participantCount: challenge.participants.length,
+ daysRemaining: challenge.getDaysRemaining()
}));
}
/**
- * Get global leaderboard
+ * Get user achievements with progress
*/
- async getGlobalLeaderboard(options = {}) {
- const { period = 'all', limit = 50 } = options;
-
- const sortField = period === 'month' ? 'points.currentMonth' : 'points.total';
-
- const profiles = await UserGamification.find({
- 'leaderboard.showOnPublic': true
- })
- .populate('user', 'name')
- .sort({ [sortField]: -1 })
- .limit(limit);
+ async getUserAchievements(userId) {
+ const profile = await this.getOrCreateProfile(userId);
+ const allAchievements = await Achievement.find({ isActive: true });
- return profiles.map((p, index) => ({
- rank: index + 1,
- user: {
- id: p.user._id,
- name: p.leaderboard.displayName || p.user.name
- },
- points: period === 'month' ? p.points.currentMonth : p.points.total,
- level: p.level.current,
- rankTitle: p.rankTitle,
- badges: p.badges.length
- }));
+ return allAchievements.map(achievement => {
+ const isEarned = profile.hasAchievement(achievement._id);
+ const progress = profile.getProgress(achievement.code);
+
+ // Hide secret achievements that aren't earned
+ if (achievement.isSecret && !isEarned) {
+ return {
+ id: achievement._id,
+ name: '???',
+ description: 'Secret achievement',
+ icon: '๐',
+ category: achievement.category,
+ tier: achievement.tier,
+ isSecret: true,
+ isEarned: false,
+ progress: 0
+ };
+ }
+
+ return {
+ id: achievement._id,
+ code: achievement.code,
+ name: achievement.name,
+ description: achievement.description,
+ icon: achievement.icon,
+ category: achievement.category,
+ tier: achievement.tier,
+ points: achievement.points,
+ rarity: achievement.rarity,
+ isEarned,
+ earnedAt: isEarned
+ ? profile.earnedAchievements.find(ea => ea.achievement.toString() === achievement._id.toString())?.earnedAt
+ : null,
+ progress: progress
+ ? Math.min(100, Math.round((progress.currentValue / progress.targetValue) * 100))
+ : 0,
+ currentValue: progress?.currentValue || 0,
+ targetValue: achievement.requirement.value
+ };
+ });
}
/**
- * Process daily challenge updates (called by cron)
+ * Track expense for gamification
*/
- async processDailyChallenges() {
- console.log('[Gamification] Processing daily challenge updates...');
+ async trackExpense(userId, expense) {
+ const profile = await this.getOrCreateProfile(userId);
- const now = new Date();
+ // Update stats
+ profile.stats.totalExpensesTracked++;
+ await profile.save();
- // Update challenge statuses
- await Challenge.updateMany(
- { status: 'upcoming', startDate: { $lte: now } },
- { status: 'active' }
- );
+ // Check expense tracking achievements
+ await this.checkAchievement(userId, 'expense_tracking', profile.stats.totalExpensesTracked);
- await Challenge.updateMany(
- { status: 'active', endDate: { $lt: now } },
- { status: 'completed' }
- );
+ // Update expense tracking streak
+ await this.updateStreak(userId, 'expense_tracking');
- // Update participant statuses for ended challenges
- const endedChallenges = await Challenge.find({ status: 'completed' });
- for (const challenge of endedChallenges) {
- await ChallengeParticipant.updateMany(
- { challenge: challenge._id, status: 'active' },
- { status: 'completed' }
- );
- }
+ // Award points for tracking
+ await this.awardPoints(userId, 1, 'Tracked an expense');
- // Determine winners for completed challenges
- for (const challenge of endedChallenges) {
- const topParticipant = await ChallengeParticipant.findOne({
- challenge: challenge._id,
- status: 'completed'
- })
- .sort({ 'progress.current': -1 })
- .populate('user');
-
- if (topParticipant && topParticipant.progress.current >= topParticipant.progress.target) {
- // Award bonus points to winner
- const bonusPoints = Math.round(challenge.rewards.points * 0.5);
- await this.addPoints(topParticipant.user._id, bonusPoints, `Challenge winner: ${challenge.title}`);
-
- // Update winner's stats
- const profile = await this.getUserProfile(topParticipant.user._id);
- profile.stats.challengesWon++;
- await profile.save();
-
- // Check achievements
- await this.checkAchievements(topParticipant.user._id);
- }
- }
+ // Update active challenges
+ const activeChallenges = await Challenge.find({
+ 'participants.user': userId,
+ status: 'active'
+ });
- console.log('[Gamification] Daily challenge processing complete');
+ for (const challenge of activeChallenges) {
+ await this.calculateChallengeProgress(userId, challenge._id);
+ }
}
/**
- * Create default system challenges
+ * Track login for streak
*/
- async createSystemChallenges() {
- for (const challengeData of DEFAULT_CHALLENGES) {
- // Create challenge starting next week
- const startDate = new Date();
- startDate.setDate(startDate.getDate() + 7);
- startDate.setHours(0, 0, 0, 0);
+ async trackLogin(userId) {
+ const profile = await this.getOrCreateProfile(userId);
+ const now = new Date();
+ const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
+ const lastLogin = profile.stats.lastLoginDate
+ ? new Date(profile.stats.lastLoginDate)
+ : null;
+
+ if (!lastLogin || lastLogin < today) {
+ profile.stats.lastLoginDate = now;
- const endDate = new Date(startDate);
- endDate.setDate(endDate.getDate() + 30);
+ // Update login streak
+ if (lastLogin) {
+ const yesterday = new Date(today);
+ yesterday.setDate(yesterday.getDate() - 1);
+
+ if (lastLogin >= yesterday) {
+ profile.stats.loginStreak++;
+ } else {
+ profile.stats.loginStreak = 1;
+ }
+ } else {
+ profile.stats.loginStreak = 1;
+ }
- const existing = await Challenge.findOne({
- title: challengeData.title,
- isSystemChallenge: true,
- startDate: { $gte: new Date() }
- });
+ await profile.save();
- if (!existing) {
- await Challenge.create({
- ...challengeData,
- startDate,
- endDate,
- isSystemChallenge: true,
- scope: 'public',
- status: 'upcoming'
- });
- console.log(`[Gamification] Created system challenge: ${challengeData.title}`);
- }
+ // Check login streak achievements
+ await this.checkAchievement(userId, 'login_streak', profile.stats.loginStreak);
+
+ // Award daily login points
+ await this.awardPoints(userId, 2, 'Daily login');
}
+
+ return profile;
}
/**
- * Record goal completion for achievements
+ * Track analytics view
*/
- async recordGoalCompletion(userId) {
- const profile = await this.getUserProfile(userId);
- profile.stats.goalsCompleted++;
+ async trackAnalyticsView(userId) {
+ const profile = await this.getOrCreateProfile(userId);
+ profile.stats.analyticsViews++;
await profile.save();
- await this.checkAchievements(userId);
+ await this.checkAchievement(userId, 'analytics_usage', profile.stats.analyticsViews);
}
/**
- * Record savings for achievements
+ * Track goal completion
*/
- async recordSavings(userId, amount) {
- const profile = await this.getUserProfile(userId);
- profile.stats.totalSaved += amount;
- profile.updateStreak('savings', true);
+ async trackGoalCompletion(userId) {
+ const profile = await this.getOrCreateProfile(userId);
+ profile.stats.totalGoalsCompleted++;
await profile.save();
- await this.checkAchievements(userId);
+ await this.checkAchievement(userId, 'goal_completion', profile.stats.totalGoalsCompleted);
+ await this.awardPoints(userId, 50, 'Completed a financial goal');
}
/**
- * Track dashboard visit for achievements
+ * Track receipt upload
*/
- async trackDashboardVisit(userId) {
- const profile = await this.getUserProfile(userId);
- profile.updateStreak('login', true);
- profile.lastActivityAt = new Date();
+ async trackReceiptUpload(userId) {
+ const profile = await this.getOrCreateProfile(userId);
+ profile.stats.totalReceiptsUploaded++;
await profile.save();
- await this.checkAchievements(userId);
+ await this.checkAchievement(userId, 'receipt_uploads', profile.stats.totalReceiptsUploaded);
+ await this.awardPoints(userId, 3, 'Uploaded a receipt');
}
/**
- * Track expense logging for achievements
+ * Initialize default achievements
*/
- async trackExpenseLogged(userId) {
- const profile = await this.getUserProfile(userId);
- profile.updateStreak('logging', true);
- await profile.save();
+ async initializeDefaultAchievements() {
+ const defaultAchievements = [
+ // Savings achievements
+ {
+ code: 'first_savings',
+ name: 'First Steps',
+ description: 'Track your first expense',
+ icon: '๐ฑ',
+ category: 'tracking',
+ tier: 'bronze',
+ points: 10,
+ requirement: { type: 'expense_tracking', value: 1 },
+ rarity: 'common'
+ },
+ {
+ code: 'expense_tracker_50',
+ name: 'Dedicated Tracker',
+ description: 'Track 50 expenses',
+ icon: '๐',
+ category: 'tracking',
+ tier: 'silver',
+ points: 25,
+ requirement: { type: 'expense_tracking', value: 50 },
+ rarity: 'uncommon'
+ },
+ {
+ code: 'expense_tracker_500',
+ name: 'Tracking Master',
+ description: 'Track 500 expenses',
+ icon: '๐',
+ category: 'tracking',
+ tier: 'gold',
+ points: 100,
+ requirement: { type: 'expense_tracking', value: 500 },
+ rarity: 'rare'
+ },
+ // Budget achievements
+ {
+ code: 'budget_master_7',
+ name: 'Budget Beginner',
+ description: 'Stay under budget for 7 days',
+ icon: '๐ฐ',
+ category: 'budgeting',
+ tier: 'bronze',
+ points: 20,
+ requirement: { type: 'budget_streak', value: 7 },
+ rarity: 'common'
+ },
+ {
+ code: 'budget_master_30',
+ name: 'Budget Pro',
+ description: 'Stay under budget for 30 days',
+ icon: '๐',
+ category: 'budgeting',
+ tier: 'silver',
+ points: 50,
+ requirement: { type: 'budget_streak', value: 30 },
+ rarity: 'uncommon'
+ },
+ {
+ code: 'budget_master_90',
+ name: 'Budget Master',
+ description: 'Stay under budget for 3 months',
+ icon: '๐',
+ category: 'budgeting',
+ tier: 'gold',
+ points: 150,
+ requirement: { type: 'budget_streak', value: 90 },
+ rarity: 'rare'
+ },
+ // Streak achievements
+ {
+ code: 'login_streak_7',
+ name: 'Week Warrior',
+ description: 'Log in 7 days in a row',
+ icon: '๐ฅ',
+ category: 'streaks',
+ tier: 'bronze',
+ points: 15,
+ requirement: { type: 'login_streak', value: 7 },
+ rarity: 'common'
+ },
+ {
+ code: 'login_streak_30',
+ name: 'Monthly Master',
+ description: 'Log in 30 days in a row',
+ icon: 'โญ',
+ category: 'streaks',
+ tier: 'silver',
+ points: 50,
+ requirement: { type: 'login_streak', value: 30 },
+ rarity: 'uncommon'
+ },
+ {
+ code: 'savings_streak_30',
+ name: 'Savings Streak',
+ description: 'Save consistently for 30 days',
+ icon: '๐',
+ category: 'savings',
+ tier: 'gold',
+ points: 75,
+ requirement: { type: 'savings_amount', value: 30, timeframe: 'daily' },
+ rarity: 'rare'
+ },
+ // Goal achievements
+ {
+ code: 'goal_crusher_1',
+ name: 'Goal Getter',
+ description: 'Complete your first financial goal',
+ icon: '๐ฏ',
+ category: 'milestones',
+ tier: 'bronze',
+ points: 25,
+ requirement: { type: 'goal_completion', value: 1 },
+ rarity: 'common'
+ },
+ {
+ code: 'goal_crusher_5',
+ name: 'Goal Crusher',
+ description: 'Complete 5 financial goals',
+ icon: '๐
',
+ category: 'milestones',
+ tier: 'gold',
+ points: 100,
+ requirement: { type: 'goal_completion', value: 5 },
+ rarity: 'rare'
+ },
+ // Challenge achievements
+ {
+ code: 'challenge_winner_1',
+ name: 'Challenge Accepted',
+ description: 'Complete your first challenge',
+ icon: '๐ฎ',
+ category: 'challenges',
+ tier: 'bronze',
+ points: 20,
+ requirement: { type: 'challenge_wins', value: 1 },
+ rarity: 'common'
+ },
+ {
+ code: 'challenge_winner_10',
+ name: 'Challenge Champion',
+ description: 'Complete 10 challenges',
+ icon: '๐',
+ category: 'challenges',
+ tier: 'gold',
+ points: 150,
+ requirement: { type: 'challenge_wins', value: 10 },
+ rarity: 'rare'
+ },
+ // Analytics achievements
+ {
+ code: 'analytics_pro',
+ name: 'Analytics Pro',
+ description: 'Check your dashboard 7 days in a row',
+ icon: '๐',
+ category: 'tracking',
+ tier: 'silver',
+ points: 30,
+ requirement: { type: 'analytics_usage', value: 7 },
+ rarity: 'uncommon'
+ },
+ // No spend achievements
+ {
+ code: 'no_spend_3',
+ name: 'Frugal Start',
+ description: 'Have 3 no-spend days',
+ icon: '๐ฟ',
+ category: 'savings',
+ tier: 'bronze',
+ points: 15,
+ requirement: { type: 'no_spend_days', value: 3 },
+ rarity: 'common'
+ },
+ {
+ code: 'no_spend_10',
+ name: 'Frugal Master',
+ description: 'Have 10 no-spend days',
+ icon: '๐ณ',
+ category: 'savings',
+ tier: 'silver',
+ points: 40,
+ requirement: { type: 'no_spend_days', value: 10 },
+ rarity: 'uncommon'
+ },
+ // Receipt achievements
+ {
+ code: 'receipt_hunter_10',
+ name: 'Receipt Collector',
+ description: 'Upload 10 receipts',
+ icon: '๐งพ',
+ category: 'tracking',
+ tier: 'bronze',
+ points: 20,
+ requirement: { type: 'receipt_uploads', value: 10 },
+ rarity: 'common'
+ },
+ {
+ code: 'receipt_hunter_50',
+ name: 'Receipt Master',
+ description: 'Upload 50 receipts',
+ icon: '๐',
+ category: 'tracking',
+ tier: 'silver',
+ points: 50,
+ requirement: { type: 'receipt_uploads', value: 50 },
+ rarity: 'uncommon'
+ }
+ ];
- await this.checkAchievements(userId);
+ for (const achievement of defaultAchievements) {
+ await Achievement.findOneAndUpdate(
+ { code: achievement.code },
+ achievement,
+ { upsert: true, new: true }
+ );
+ }
+
+ console.log('Default achievements initialized');
+ }
+
+ /**
+ * Reset weekly/monthly points
+ */
+ async resetPeriodicPoints(type) {
+ const now = new Date();
+
+ if (type === 'weekly') {
+ await UserGamification.updateMany(
+ {},
+ {
+ $set: {
+ weeklyPoints: 0,
+ lastWeeklyReset: now
+ }
+ }
+ );
+ } else if (type === 'monthly') {
+ await UserGamification.updateMany(
+ {},
+ {
+ $set: {
+ monthlyPoints: 0,
+ lastMonthlyReset: now
+ }
+ }
+ );
+ }
+ }
+
+ /**
+ * Check and update all active challenges status
+ */
+ async updateChallengeStatuses() {
+ const now = new Date();
+
+ // Mark started challenges as active
+ await Challenge.updateMany(
+ {
+ status: 'upcoming',
+ startDate: { $lte: now }
+ },
+ { $set: { status: 'active' } }
+ );
+
+ // Mark ended challenges as completed
+ const endedChallenges = await Challenge.find({
+ status: 'active',
+ endDate: { $lt: now }
+ });
+
+ for (const challenge of endedChallenges) {
+ challenge.status = 'completed';
+
+ // Mark remaining active participants as failed
+ for (const participant of challenge.participants) {
+ if (participant.status === 'active') {
+ participant.status = 'failed';
+ }
+ }
+
+ await challenge.save();
+ }
}
}