feat: implement comprehensive gamification, analytics, and leaderboard system

This commit adds a complete gamification system with analytics dashboards,
leaderboards, and enhanced badge tracking functionality.

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

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

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

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

🤖 Generated with OpenCode

Co-Authored-By: AI Assistant <noreply@opencode.ai>
This commit is contained in:
William Valentin
2025-11-03 13:53:48 -08:00
parent ae77e30ffb
commit 3e4c730860
34 changed files with 5533 additions and 190 deletions
+14
View File
@@ -1,4 +1,18 @@
// This file runs before any modules are loaded
// Set test environment variables FIRST (before any module loads)
// Must be at least 32 chars for validation
process.env.NODE_ENV = 'test';
process.env.JWT_SECRET = 'test-jwt-secret-for-testing-purposes-that-is-long-enough';
process.env.COUCHDB_URL = 'http://localhost:5984';
process.env.COUCHDB_DB_NAME = 'test-adopt-a-street';
process.env.PORT = '5001';
// Mock dotenv to prevent .env file from overriding test values
jest.mock('dotenv', () => ({
config: jest.fn()
}));
// Mock axios first since couchdbService uses it
jest.mock('axios', () => ({
create: jest.fn(() => ({
+488
View File
@@ -0,0 +1,488 @@
const request = require("supertest");
const { app } = require("../../server");
const couchdbService = require("../../services/couchdbService");
const jwt = require("jsonwebtoken");
// Mock couchdbService
jest.mock("../../services/couchdbService");
describe("Analytics API", () => {
let authToken;
let mockUser;
beforeAll(() => {
// Create a mock user and token
mockUser = {
_id: "user_123",
type: "user",
name: "Test User",
email: "test@test.com",
points: 100,
isPremium: false,
adoptedStreets: ["street_1"],
completedTasks: ["task_1", "task_2"],
posts: ["post_1"],
events: ["event_1"],
earnedBadges: [{ badgeId: "badge_1" }],
stats: {
streetsAdopted: 1,
tasksCompleted: 2,
postsCreated: 1,
eventsParticipated: 1,
badgesEarned: 1,
},
};
authToken = jwt.sign({ id: mockUser._id }, process.env.JWT_SECRET || "test_secret");
// Mock couchdbService methods
couchdbService.find = jest.fn();
couchdbService.findUserById = jest.fn();
couchdbService.getDocument = jest.fn();
});
afterEach(() => {
jest.clearAllMocks();
});
describe("GET /api/analytics/overview", () => {
it("should return overview statistics without auth token", async () => {
const mockUsers = [
{ type: "user", points: 100 },
{ type: "user", points: 150 },
];
const mockStreets = [
{ type: "street", status: "adopted" },
{ type: "street", status: "available" },
{ type: "street", status: "adopted" },
];
const mockTasks = [
{ type: "task", status: "completed" },
{ type: "task", status: "pending" },
];
const mockEvents = [
{ type: "event", status: "upcoming" },
{ type: "event", status: "completed" },
];
const mockPosts = [{ type: "post" }, { type: "post" }];
couchdbService.find.mockImplementation(async (query) => {
if (query.selector.type === "user") return mockUsers;
if (query.selector.type === "street") return mockStreets;
if (query.selector.type === "task") return mockTasks;
if (query.selector.type === "event") return mockEvents;
if (query.selector.type === "post") return mockPosts;
return [];
});
const res = await request(app)
.get("/api/analytics/overview")
.set("x-auth-token", authToken);
expect(res.statusCode).toBe(200);
expect(res.body).toHaveProperty("overview");
expect(res.body.overview).toHaveProperty("totalUsers", 2);
expect(res.body.overview).toHaveProperty("totalStreets", 3);
expect(res.body.overview).toHaveProperty("adoptedStreets", 2);
expect(res.body.overview).toHaveProperty("totalTasks", 2);
expect(res.body.overview).toHaveProperty("completedTasks", 1);
expect(res.body.overview).toHaveProperty("totalEvents", 2);
expect(res.body.overview).toHaveProperty("totalPosts", 2);
expect(res.body.overview).toHaveProperty("totalPoints", 250);
expect(res.body.overview).toHaveProperty("averagePointsPerUser", 125);
});
it("should filter by timeframe", async () => {
const now = new Date();
const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
const mockUsers = [{ type: "user", points: 100 }];
const mockStreets = [{ type: "street", status: "adopted" }];
const mockTasks = [
{ type: "task", status: "completed", createdAt: now.toISOString() },
];
const mockEvents = [
{ type: "event", status: "upcoming", createdAt: now.toISOString() },
];
const mockPosts = [{ type: "post", createdAt: now.toISOString() }];
couchdbService.find.mockImplementation(async (query) => {
if (query.selector.type === "user") return mockUsers;
if (query.selector.type === "street") return mockStreets;
if (query.selector.type === "task") return mockTasks;
if (query.selector.type === "event") return mockEvents;
if (query.selector.type === "post") return mockPosts;
return [];
});
const res = await request(app)
.get("/api/analytics/overview?timeframe=7d")
.set("x-auth-token", authToken);
expect(res.statusCode).toBe(200);
expect(res.body.timeframe).toBe("7d");
});
it("should require authentication", async () => {
const res = await request(app).get("/api/analytics/overview");
expect(res.statusCode).toBe(401);
});
});
describe("GET /api/analytics/user/:userId", () => {
it("should return user-specific analytics", async () => {
couchdbService.findUserById.mockResolvedValue(mockUser);
couchdbService.getDocument.mockResolvedValue({
_id: "street_1",
name: "Main Street",
});
const mockTasks = [
{ type: "task", completedBy: { userId: mockUser._id }, createdAt: new Date().toISOString() },
];
const mockPosts = [
{ type: "post", user: { userId: mockUser._id }, likesCount: 5, commentsCount: 3, createdAt: new Date().toISOString() },
];
const mockEvents = [
{ type: "event", participants: [{ userId: mockUser._id }], createdAt: new Date().toISOString() },
];
const mockTransactions = [
{ type: "point_transaction", user: { userId: mockUser._id }, amount: 50, createdAt: new Date().toISOString() },
{ type: "point_transaction", user: { userId: mockUser._id }, amount: -20, createdAt: new Date().toISOString() },
];
couchdbService.find.mockImplementation(async (query) => {
if (query.selector.type === "task") return mockTasks;
if (query.selector.type === "post") return mockPosts;
if (query.selector.type === "event") return mockEvents;
if (query.selector.type === "point_transaction") return mockTransactions;
return [];
});
const res = await request(app)
.get(`/api/analytics/user/${mockUser._id}`)
.set("x-auth-token", authToken);
expect(res.statusCode).toBe(200);
expect(res.body).toHaveProperty("user");
expect(res.body.user.name).toBe("Test User");
expect(res.body).toHaveProperty("stats");
expect(res.body.stats).toHaveProperty("streetsAdopted");
expect(res.body.stats).toHaveProperty("tasksCompleted");
expect(res.body.stats).toHaveProperty("pointsEarned", 50);
expect(res.body.stats).toHaveProperty("pointsSpent", 20);
expect(res.body.stats).toHaveProperty("totalLikesReceived", 5);
expect(res.body.stats).toHaveProperty("totalCommentsReceived", 3);
expect(res.body).toHaveProperty("recentActivity");
});
it("should return 404 for non-existent user", async () => {
couchdbService.findUserById.mockResolvedValue(null);
const res = await request(app)
.get("/api/analytics/user/invalid_user_id")
.set("x-auth-token", authToken);
expect(res.statusCode).toBe(404);
expect(res.body.msg).toBe("User not found");
});
it("should require authentication", async () => {
const res = await request(app).get(`/api/analytics/user/${mockUser._id}`);
expect(res.statusCode).toBe(401);
});
});
describe("GET /api/analytics/activity", () => {
it("should return activity data grouped by day", async () => {
const today = new Date().toISOString();
const mockTasks = [{ type: "task", createdAt: today }];
const mockPosts = [{ type: "post", createdAt: today }];
const mockEvents = [{ type: "event", createdAt: today }];
const mockStreets = [{ type: "street", status: "adopted", createdAt: today }];
couchdbService.find.mockImplementation(async (query) => {
if (query.selector.type === "task") return mockTasks;
if (query.selector.type === "post") return mockPosts;
if (query.selector.type === "event") return mockEvents;
if (query.selector.type === "street") return mockStreets;
return [];
});
const res = await request(app)
.get("/api/analytics/activity?groupBy=day")
.set("x-auth-token", authToken);
expect(res.statusCode).toBe(200);
expect(res.body).toHaveProperty("activity");
expect(res.body).toHaveProperty("groupBy", "day");
expect(res.body).toHaveProperty("summary");
expect(Array.isArray(res.body.activity)).toBe(true);
});
it("should group activity by week", async () => {
const today = new Date().toISOString();
const mockTasks = [{ type: "task", createdAt: today }];
const mockPosts = [];
const mockEvents = [];
const mockStreets = [];
couchdbService.find.mockImplementation(async (query) => {
if (query.selector.type === "task") return mockTasks;
if (query.selector.type === "post") return mockPosts;
if (query.selector.type === "event") return mockEvents;
if (query.selector.type === "street") return mockStreets;
return [];
});
const res = await request(app)
.get("/api/analytics/activity?groupBy=week")
.set("x-auth-token", authToken);
expect(res.statusCode).toBe(200);
expect(res.body.groupBy).toBe("week");
});
it("should group activity by month", async () => {
const today = new Date().toISOString();
const mockTasks = [{ type: "task", createdAt: today }];
const mockPosts = [];
const mockEvents = [];
const mockStreets = [];
couchdbService.find.mockImplementation(async (query) => {
if (query.selector.type === "task") return mockTasks;
if (query.selector.type === "post") return mockPosts;
if (query.selector.type === "event") return mockEvents;
if (query.selector.type === "street") return mockStreets;
return [];
});
const res = await request(app)
.get("/api/analytics/activity?groupBy=month")
.set("x-auth-token", authToken);
expect(res.statusCode).toBe(200);
expect(res.body.groupBy).toBe("month");
});
it("should require authentication", async () => {
const res = await request(app).get("/api/analytics/activity");
expect(res.statusCode).toBe(401);
});
});
describe("GET /api/analytics/top-contributors", () => {
it("should return top contributors by points", async () => {
const mockUsers = [
{
_id: "user_1",
name: "User 1",
email: "user1@test.com",
points: 500,
stats: { tasksCompleted: 10, postsCreated: 5, streetsAdopted: 2 },
earnedBadges: [],
},
{
_id: "user_2",
name: "User 2",
email: "user2@test.com",
points: 300,
stats: { tasksCompleted: 7, postsCreated: 3, streetsAdopted: 1 },
earnedBadges: [],
},
];
couchdbService.find.mockResolvedValue(mockUsers);
const res = await request(app)
.get("/api/analytics/top-contributors?limit=5")
.set("x-auth-token", authToken);
expect(res.statusCode).toBe(200);
expect(res.body).toHaveProperty("contributors");
expect(res.body.contributors.length).toBeLessThanOrEqual(5);
expect(res.body.metric).toBe("points");
// Verify sorted by score descending
if (res.body.contributors.length > 1) {
expect(res.body.contributors[0].score).toBeGreaterThanOrEqual(
res.body.contributors[1].score
);
}
});
it("should return top contributors by tasks", async () => {
const mockUsers = [
{
_id: "user_1",
name: "User 1",
email: "user1@test.com",
points: 500,
stats: { tasksCompleted: 10, postsCreated: 5, streetsAdopted: 2 },
earnedBadges: [],
},
];
couchdbService.find.mockResolvedValue(mockUsers);
const res = await request(app)
.get("/api/analytics/top-contributors?metric=tasks")
.set("x-auth-token", authToken);
expect(res.statusCode).toBe(200);
expect(res.body.metric).toBe("tasks");
});
it("should return top contributors by posts", async () => {
const mockUsers = [
{
_id: "user_1",
name: "User 1",
email: "user1@test.com",
points: 500,
stats: { tasksCompleted: 10, postsCreated: 5, streetsAdopted: 2 },
earnedBadges: [],
},
];
couchdbService.find.mockResolvedValue(mockUsers);
const res = await request(app)
.get("/api/analytics/top-contributors?metric=posts")
.set("x-auth-token", authToken);
expect(res.statusCode).toBe(200);
expect(res.body.metric).toBe("posts");
});
it("should return top contributors by streets", async () => {
const mockUsers = [
{
_id: "user_1",
name: "User 1",
email: "user1@test.com",
points: 500,
stats: { tasksCompleted: 10, postsCreated: 5, streetsAdopted: 2 },
earnedBadges: [],
},
];
couchdbService.find.mockResolvedValue(mockUsers);
const res = await request(app)
.get("/api/analytics/top-contributors?metric=streets")
.set("x-auth-token", authToken);
expect(res.statusCode).toBe(200);
expect(res.body.metric).toBe("streets");
});
it("should require authentication", async () => {
const res = await request(app).get("/api/analytics/top-contributors");
expect(res.statusCode).toBe(401);
});
});
describe("GET /api/analytics/street-stats", () => {
it("should return street adoption and task statistics", async () => {
const mockStreets = [
{ type: "street", status: "adopted" },
{ type: "street", status: "available" },
{ type: "street", status: "adopted" },
];
const mockTasks = [
{
type: "task",
status: "completed",
street: { streetId: "street_1", name: "Main St" },
},
{
type: "task",
status: "completed",
street: { streetId: "street_1", name: "Main St" },
},
{ type: "task", status: "pending", street: { streetId: "street_2", name: "Oak St" } },
];
couchdbService.find.mockImplementation(async (query) => {
if (query.selector.type === "street") return mockStreets;
if (query.selector.type === "task") return mockTasks;
return [];
});
const res = await request(app)
.get("/api/analytics/street-stats")
.set("x-auth-token", authToken);
expect(res.statusCode).toBe(200);
expect(res.body).toHaveProperty("adoption");
expect(res.body.adoption).toHaveProperty("totalStreets", 3);
expect(res.body.adoption).toHaveProperty("adoptedStreets", 2);
expect(res.body.adoption).toHaveProperty("availableStreets", 1);
expect(res.body.adoption).toHaveProperty("adoptionRate");
expect(res.body).toHaveProperty("tasks");
expect(res.body.tasks).toHaveProperty("totalTasks", 3);
expect(res.body.tasks).toHaveProperty("completedTasks", 2);
expect(res.body.tasks).toHaveProperty("pendingTasks", 1);
expect(res.body.tasks).toHaveProperty("completionRate");
expect(res.body).toHaveProperty("topStreets");
expect(Array.isArray(res.body.topStreets)).toBe(true);
});
it("should calculate correct adoption rate", async () => {
const mockStreets = [
{ type: "street", status: "adopted" },
{ type: "street", status: "adopted" },
{ type: "street", status: "available" },
{ type: "street", status: "available" },
];
const mockTasks = [];
couchdbService.find.mockImplementation(async (query) => {
if (query.selector.type === "street") return mockStreets;
if (query.selector.type === "task") return mockTasks;
return [];
});
const res = await request(app)
.get("/api/analytics/street-stats")
.set("x-auth-token", authToken);
expect(res.statusCode).toBe(200);
expect(res.body.adoption.adoptionRate).toBe(50.0);
});
it("should calculate correct task completion rate", async () => {
const mockStreets = [];
const mockTasks = [
{ type: "task", status: "completed" },
{ type: "task", status: "completed" },
{ type: "task", status: "completed" },
{ type: "task", status: "pending" },
];
couchdbService.find.mockImplementation(async (query) => {
if (query.selector.type === "street") return mockStreets;
if (query.selector.type === "task") return mockTasks;
return [];
});
const res = await request(app)
.get("/api/analytics/street-stats")
.set("x-auth-token", authToken);
expect(res.statusCode).toBe(200);
expect(res.body.tasks.completionRate).toBe(75.0);
});
it("should require authentication", async () => {
const res = await request(app).get("/api/analytics/street-stats");
expect(res.statusCode).toBe(401);
});
});
});
@@ -0,0 +1,396 @@
const request = require("supertest");
const { app, server } = require("../../server");
const couchdbService = require("../../services/couchdbService");
const User = require("../../models/User");
const PointTransaction = require("../../models/PointTransaction");
const Badge = require("../../models/Badge");
const UserBadge = require("../../models/UserBadge");
const jwt = require("jsonwebtoken");
const { clearCache } = require("../../middleware/cache");
describe("Leaderboard Routes", () => {
let testUsers = [];
let authToken;
let testUserId;
beforeAll(async () => {
await couchdbService.initialize();
});
beforeEach(async () => {
// Clear cache before each test
clearCache();
// Create test users with varying points
const userPromises = [];
for (let i = 0; i < 10; i++) {
const userData = {
name: `Test User ${i}`,
email: `testuser${i}@example.com`,
password: "password123",
points: (10 - i) * 100, // 1000, 900, 800, ..., 100
adoptedStreets: [],
completedTasks: [],
stats: {
streetsAdopted: i,
tasksCompleted: i * 2,
postsCreated: i,
eventsParticipated: i,
badgesEarned: 0
}
};
userPromises.push(User.create(userData));
}
testUsers = await Promise.all(userPromises);
// Set test user ID and create auth token
testUserId = testUsers[0]._id;
authToken = jwt.sign({ id: testUserId }, process.env.JWT_SECRET, {
expiresIn: "1h"
});
// Create some point transactions for weekly/monthly leaderboards
const now = new Date();
const transactionPromises = [];
// Create transactions for this week
for (let i = 0; i < 5; i++) {
const weeklyTransaction = {
user: testUsers[i]._id,
amount: (5 - i) * 50,
transactionType: "earned",
description: `Weekly test transaction ${i}`,
balanceAfter: testUsers[i].points + (5 - i) * 50,
createdAt: new Date(now.getTime() - i * 24 * 60 * 60 * 1000).toISOString()
};
transactionPromises.push(PointTransaction.create(weeklyTransaction));
}
// Create transactions for this month (but not this week)
for (let i = 5; i < 8; i++) {
const monthlyTransaction = {
user: testUsers[i]._id,
amount: (8 - i) * 30,
transactionType: "earned",
description: `Monthly test transaction ${i}`,
balanceAfter: testUsers[i].points + (8 - i) * 30,
createdAt: new Date(now.getTime() - (i + 5) * 24 * 60 * 60 * 1000).toISOString()
};
transactionPromises.push(PointTransaction.create(monthlyTransaction));
}
await Promise.all(transactionPromises);
});
afterEach(async () => {
// Clean up test data
const deletePromises = [];
// Delete test users
for (const user of testUsers) {
if (user._id && user._rev) {
deletePromises.push(
couchdbService.deleteDocument(user._id, user._rev).catch(() => {})
);
}
}
// Delete test transactions
const transactions = await couchdbService.find({
selector: { type: "point_transaction" }
});
for (const transaction of transactions) {
deletePromises.push(
couchdbService.deleteDocument(transaction._id, transaction._rev).catch(() => {})
);
}
await Promise.all(deletePromises);
testUsers = [];
clearCache();
});
afterAll(async () => {
await new Promise((resolve) => {
server.close(resolve);
});
});
describe("GET /api/leaderboard/global", () => {
it("should get global leaderboard with top users", async () => {
const res = await request(app)
.get("/api/leaderboard/global")
.expect(200);
expect(res.body.success).toBe(true);
expect(Array.isArray(res.body.data)).toBe(true);
expect(res.body.data.length).toBeGreaterThan(0);
expect(res.body.data.length).toBeLessThanOrEqual(100);
// Check first user has highest points
const firstUser = res.body.data[0];
expect(firstUser).toHaveProperty("rank", 1);
expect(firstUser).toHaveProperty("userId");
expect(firstUser).toHaveProperty("username");
expect(firstUser).toHaveProperty("points");
expect(firstUser).toHaveProperty("streetsAdopted");
expect(firstUser).toHaveProperty("tasksCompleted");
expect(firstUser).toHaveProperty("badges");
// Verify sorting by points descending
for (let i = 0; i < res.body.data.length - 1; i++) {
expect(res.body.data[i].points).toBeGreaterThanOrEqual(
res.body.data[i + 1].points
);
}
});
it("should respect limit parameter", async () => {
const res = await request(app)
.get("/api/leaderboard/global?limit=5")
.expect(200);
expect(res.body.success).toBe(true);
expect(res.body.data.length).toBeLessThanOrEqual(5);
expect(res.body.limit).toBe(5);
});
it("should respect offset parameter", async () => {
const res1 = await request(app)
.get("/api/leaderboard/global?limit=5")
.expect(200);
const res2 = await request(app)
.get("/api/leaderboard/global?limit=5&offset=5")
.expect(200);
expect(res2.body.success).toBe(true);
expect(res2.body.offset).toBe(5);
// First user in second page should have lower points than last in first page
if (res2.body.data.length > 0 && res1.body.data.length === 5) {
expect(res2.body.data[0].points).toBeLessThanOrEqual(
res1.body.data[4].points
);
}
});
it("should cache leaderboard results", async () => {
const res1 = await request(app)
.get("/api/leaderboard/global")
.expect(200);
const res2 = await request(app)
.get("/api/leaderboard/global")
.expect(200);
expect(res1.body).toEqual(res2.body);
});
it("should limit maximum request to 500", async () => {
const res = await request(app)
.get("/api/leaderboard/global?limit=1000")
.expect(200);
expect(res.body.limit).toBe(500);
});
});
describe("GET /api/leaderboard/weekly", () => {
it("should get weekly leaderboard", async () => {
const res = await request(app)
.get("/api/leaderboard/weekly")
.expect(200);
expect(res.body.success).toBe(true);
expect(res.body.timeframe).toBe("week");
expect(Array.isArray(res.body.data)).toBe(true);
});
it("should only include points from this week", async () => {
const res = await request(app)
.get("/api/leaderboard/weekly")
.expect(200);
expect(res.body.success).toBe(true);
// Weekly leaderboard should have users with weekly transactions
if (res.body.data.length > 0) {
const firstUser = res.body.data[0];
expect(firstUser).toHaveProperty("points");
expect(firstUser).toHaveProperty("rank", 1);
}
});
it("should respect limit and offset", async () => {
const res = await request(app)
.get("/api/leaderboard/weekly?limit=3&offset=1")
.expect(200);
expect(res.body.success).toBe(true);
expect(res.body.limit).toBe(3);
expect(res.body.offset).toBe(1);
});
});
describe("GET /api/leaderboard/monthly", () => {
it("should get monthly leaderboard", async () => {
const res = await request(app)
.get("/api/leaderboard/monthly")
.expect(200);
expect(res.body.success).toBe(true);
expect(res.body.timeframe).toBe("month");
expect(Array.isArray(res.body.data)).toBe(true);
});
it("should only include points from this month", async () => {
const res = await request(app)
.get("/api/leaderboard/monthly")
.expect(200);
expect(res.body.success).toBe(true);
// Monthly leaderboard should have users with monthly transactions
if (res.body.data.length > 0) {
const firstUser = res.body.data[0];
expect(firstUser).toHaveProperty("points");
expect(firstUser).toHaveProperty("rank", 1);
}
});
});
describe("GET /api/leaderboard/friends", () => {
it("should require authentication", async () => {
const res = await request(app)
.get("/api/leaderboard/friends")
.expect(401);
expect(res.body.success).toBe(false);
});
it("should get friends leaderboard with authentication", async () => {
const res = await request(app)
.get("/api/leaderboard/friends")
.set("x-auth-token", authToken)
.expect(200);
expect(res.body.success).toBe(true);
expect(Array.isArray(res.body.data)).toBe(true);
});
it("should include user if no friends", async () => {
const res = await request(app)
.get("/api/leaderboard/friends")
.set("x-auth-token", authToken)
.expect(200);
expect(res.body.success).toBe(true);
// Should at least include the user themselves
const userEntry = res.body.data.find(entry => entry.userId === testUserId);
if (res.body.data.length > 0) {
expect(userEntry || res.body.data.length === 1).toBeTruthy();
}
});
});
describe("GET /api/leaderboard/user/:userId", () => {
it("should get user leaderboard position", async () => {
const res = await request(app)
.get(`/api/leaderboard/user/${testUserId}`)
.expect(200);
expect(res.body.success).toBe(true);
expect(res.body.data).toHaveProperty("rank");
expect(res.body.data).toHaveProperty("userId", testUserId);
expect(res.body.data).toHaveProperty("username");
expect(res.body.data).toHaveProperty("points");
expect(res.body.data).toHaveProperty("totalUsers");
expect(res.body.data).toHaveProperty("percentile");
});
it("should support timeframe parameter", async () => {
const res = await request(app)
.get(`/api/leaderboard/user/${testUserId}?timeframe=week`)
.expect(200);
expect(res.body.success).toBe(true);
expect(res.body.data).toHaveProperty("rank");
});
it("should return 404 for non-existent user", async () => {
const res = await request(app)
.get("/api/leaderboard/user/nonexistent_user_id")
.expect(404);
expect(res.body.success).toBe(false);
});
});
describe("GET /api/leaderboard/stats", () => {
it("should get leaderboard statistics", async () => {
const res = await request(app)
.get("/api/leaderboard/stats")
.expect(200);
expect(res.body.success).toBe(true);
expect(res.body.data).toHaveProperty("totalUsers");
expect(res.body.data).toHaveProperty("totalPoints");
expect(res.body.data).toHaveProperty("avgPoints");
expect(res.body.data).toHaveProperty("maxPoints");
expect(res.body.data).toHaveProperty("minPoints");
expect(res.body.data).toHaveProperty("weeklyStats");
expect(res.body.data.weeklyStats).toHaveProperty("totalPoints");
expect(res.body.data.weeklyStats).toHaveProperty("activeUsers");
expect(res.body.data.weeklyStats).toHaveProperty("transactions");
});
it("should cache statistics", async () => {
const res1 = await request(app)
.get("/api/leaderboard/stats")
.expect(200);
const res2 = await request(app)
.get("/api/leaderboard/stats")
.expect(200);
expect(res1.body).toEqual(res2.body);
});
});
describe("Edge Cases", () => {
it("should handle empty leaderboard gracefully", async () => {
// Delete all users first
const deletePromises = testUsers.map(user =>
couchdbService.deleteDocument(user._id, user._rev).catch(() => {})
);
await Promise.all(deletePromises);
clearCache();
const res = await request(app)
.get("/api/leaderboard/global")
.expect(200);
expect(res.body.success).toBe(true);
expect(res.body.data).toEqual([]);
});
it("should handle invalid limit gracefully", async () => {
const res = await request(app)
.get("/api/leaderboard/global?limit=invalid")
.expect(200);
expect(res.body.success).toBe(true);
expect(typeof res.body.limit).toBe("number");
});
it("should handle negative offset gracefully", async () => {
const res = await request(app)
.get("/api/leaderboard/global?offset=-5")
.expect(200);
expect(res.body.success).toBe(true);
});
});
});
@@ -0,0 +1,52 @@
const { body, validationResult } = require("express-validator");
const URL_REGEX = /^(https?|ftp):\\/\\/[^\\s\\/$.?#].[^\\s]*$/i;
const validateProfile = [
body("bio")
.optional()
.isLength({ max: 500 })
.withMessage("Bio cannot exceed 500 characters."),
body("location").optional().isString(),
body("website")
.optional()
.if(body("website").notEmpty())
.matches(URL_REGEX)
.withMessage("Invalid website URL."),
body("social.twitter")
.optional()
.if(body("social.twitter").notEmpty())
.matches(URL_REGEX)
.withMessage("Invalid Twitter URL."),
body("social.github")
.optional()
.if(body("social.github").notEmpty())
.matches(URL_REGEX)
.withMessage("Invalid Github URL."),
body("social.linkedin")
.optional()
.if(body("social.linkedin").notEmpty())
.matches(URL_REGEX)
.withMessage("Invalid LinkedIn URL."),
body("privacySettings.profileVisibility")
.optional()
.isIn(["public", "private"])
.withMessage("Profile visibility must be public or private."),
body("preferences.emailNotifications").optional().isBoolean(),
body("preferences.pushNotifications").optional().isBoolean(),
body("preferences.theme")
.optional()
.isIn(["light", "dark"])
.withMessage("Theme must be light or dark."),
(req, res, next) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
next();
},
];
module.exports = { validateProfile };
+64 -97
View File
@@ -1,25 +1,19 @@
const bcrypt = require("bcryptjs");
const couchdbService = require("../services/couchdbService");
const {
ValidationError,
NotFoundError,
DatabaseError,
DuplicateError,
const {
ValidationError,
withErrorHandling,
createErrorContext
createErrorContext,
} = require("../utils/modelErrors");
const URL_REGEX = /^(https?|ftp):\/\/[^\s\/$.?#].[^\s]*$/i;
class User {
constructor(data) {
// Validate required fields
if (!data.name) {
throw new ValidationError('Name is required', 'name', data.name);
}
if (!data.email) {
throw new ValidationError('Email is required', 'email', data.email);
}
if (!data.password) {
throw new ValidationError('Password is required', 'password', data.password);
constructor(data) { // Validate required fields for new user creation
if (!data._id) { // Only for new users
if (!data.name) { throw new ValidationError("Name is required", "name", data.name); }
if (!data.email) { throw new ValidationError("Email is required", "email", data.email); }
if (!data.password) { throw new ValidationError("Password is required", "password", data.password); }
}
this._id = data._id || null;
@@ -28,39 +22,60 @@ class User {
this.name = data.name;
this.email = data.email;
this.password = data.password;
// --- Profile Information ---
this.avatar = data.avatar || null;
this.cloudinaryPublicId = data.cloudinaryPublicId || null;
this.bio = data.bio || "";
if (this.bio.length > 510) { throw new ValidationError("Bio cannot exceed 500 characters.", "bio", this.bio); }
this.location = data.location || "";
this.website = data.website || "";
if (this.website && !URL_REGEX.test(this.website)) { throw new ValidationError("Invalid website URL.", "website", this.website); }
// --- Social Links ---
this.social = data.social || { twitter: "", github: "", linkedin: "" };
if (this.social.twitter && !URL_REGEX.test(this.social.twitter)) { throw new ValidationError("Invalid Twitter URL.", "social.twitter", this.social.twitter); }
if (this.social.github && !URL_REGEX.test(this.social.github)) { throw new ValidationError("Invalid Github URL.", "social.github", this.social.github); }
if (this.social.linkedin && !URL_REGEX.test(this.social.linkedin)) { throw new ValidationError("Invalid LinkedIn URL.", "social.linkedin", this.social.linkedin); }
// --- Settings & Preferences ---
this.privacySettings = data.privacySettings || { profileVisibility: "public" };
if (!["public", "private"].includes(this.privacySettings.profileVisibility)) { throw new ValidationError("Profile visibility must be 'public' or 'private.", "privacySettings.profileVisibility", this.privacySettings.profileVisibility); }
this.preferences = data.preferences || { emailNotifications: true, pushNotifications: true, theme: "light" };
if (!["light", "dark"].includes(this.preferences.theme)) { throw new ValidationError("Theme must be light' or 'dark'.", "preferences.theme", this.preferences.theme); }
// --- Gamification & App Data ---
this.isPremium = data.isPremium || false;
this.points = Math.max(0, data.points || 0); // Ensure non-negative
this.points = Math.max(0, data.points || 0);
this.adoptedStreets = data.adoptedStreets || [];
this.completedTasks = data.completedTasks || [];
this.posts = data.posts || [];
this.events = data.events || [];
this.profilePicture = data.profilePicture || null;
this.cloudinaryPublicId = data.cloudinaryPublicId || null;
this.earnedBadges = data.earnedBadges || [];
this.stats = data.stats || {
streetsAdopted: 0,
tasksCompleted: 0,
postsCreated: 0,
eventsParticipated: 0,
badgesEarned: 0
badgesEarned: 0,
};
this.createdAt = data.createdAt || new Date().toISOString();
this.updatedAt = data.updatedAt || new Date().toISOString();
}
// ... (static methods remain the same)
// Static methods for MongoDB compatibility
static async findOne(query) {
const errorContext = createErrorContext('User', 'findOne', { query });
return await withErrorHandling(async () => {
let user;
if (query.email) {
user = await couchdbService.findUserByEmail(query.email);
} else if (query._id) {
user = await couchdbService.findUserById(query._id);
} else {
// Generic query fallback
const docs = await couchdbService.find({
if (query.email) { user = await couchdbService.findUserByEmail(query.email); }
else if (query._id) { user = await couchdeService.findUserById(query._id); }
else { // Generic query fallback
const docs = await couchdeService.find({
selector: { type: "user", ...query },
limit: 1
});
@@ -74,7 +89,7 @@ class User {
const errorContext = createErrorContext('User', 'findById', { id });
return await withErrorHandling(async () => {
const user = await couchdbService.findUserById(id);
const user = await couchdeService.findUserById(id);
return user ? new User(user) : null;
}, errorContext);
}
@@ -83,16 +98,14 @@ class User {
const errorContext = createErrorContext('User', 'findByIdAndUpdate', { id, update, options });
return await withErrorHandling(async () => {
const user = await couchdbService.findUserById(id);
const user = await couchdeService.findUserById(id);
if (!user) return null;
const updatedUser = { ...user, ...update, updatedAt: new Date().toISOString() };
const saved = await couchdbService.update(id, updatedUser);
const saved = await couchdeService.update(id, updatedUser);
if (options.new) {
return saved;
}
return user;
if (options.new) { return new User(saved); }
return new User(user);
}, errorContext);
}
@@ -100,7 +113,7 @@ class User {
const errorContext = createErrorContext('User', 'findByIdAndDelete', { id });
return await withErrorHandling(async () => {
const user = await couchdbService.findUserById(id);
const user = await couchdeService.findUserById(id);
if (!user) return null;
await couchdbService.delete(id);
@@ -113,7 +126,8 @@ class User {
return await withErrorHandling(async () => {
const selector = { type: "user", ...query };
return await couchdbService.find({ selector });
const users = await couchdbService.find({ selector });
return users.map(u => new User(u));
}, errorContext);
}
@@ -123,23 +137,15 @@ class User {
return await withErrorHandling(async () => {
const user = new User(userData);
// Hash password if provided
if (user.password) {
const salt = await bcrypt.genSalt(10);
user.password = await bcrypt.hash(user.password, salt);
}
if (user.password) { const salt = await bcrypt.genSalt(10); user.password = await bcrypt.hash(user.password, salt); }
// Generate ID if not provided
if (!user._id) {
user._id = `user_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
}
if (!user._id) { user._id = `user_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; }
const created = await couchdbService.createDocument(user.toJSON());
return new User(created);
}, errorContext);
}
// Instance methods
async save() {
const errorContext = createErrorContext('User', 'save', {
id: this._id,
@@ -148,23 +154,18 @@ class User {
});
return await withErrorHandling(async () => {
this.updatedAt = new Date().toISOString();
if (!this._id) {
// New document
this._id = `user_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
// Hash password if not already hashed
if (this.password && !this.password.startsWith('$2')) {
if (this.password && !this.password.startsWith('$2)')) {
const salt = await bcrypt.genSalt(10);
this.password = await bcrypt.hash(this.password, salt);
}
const created = await couchdbService.createDocument(this.toJSON());
this._rev = created._rev;
return this;
} else {
// Update existing document
this.updatedAt = new Date().toISOString();
const updated = await couchdbService.updateDocument(this.toJSON());
const updated = await couchdeService.updateDocument(this.toJSON());
this._rev = updated._rev;
return this;
}
@@ -182,14 +183,12 @@ class User {
}, errorContext);
}
// Helper method to get user without password
toSafeObject() {
const obj = this.toJSON();
delete obj.password;
return obj;
}
// Convert to CouchDB document format
toJSON() {
return {
_id: this._id,
@@ -198,58 +197,26 @@ class User {
name: this.name,
email: this.email,
password: this.password,
avatar: this.avatar,
cloudinaryPublicId: this.cloudinaryPublicId,
bio: this.bio,
location: this.location,
website: this.website,
social: this.social,
privacySettings: this.privacySettings,
preferences: this.preferences,
isPremium: this.isPremium,
points: this.points,
adoptedStreets: this.adoptedStreets,
completedTasks: this.completedTasks,
posts: this.posts,
events: this.events,
profilePicture: this.profilePicture,
cloudinaryPublicId: this.cloudinaryPublicId,
earnedBadges: this.earnedBadges,
stats: this.stats,
createdAt: this.createdAt,
updatedAt: this.updatedAt
updatedAt: this.updatedAt,
};
}
// Static method for select functionality
static async select(fields) {
const errorContext = createErrorContext('User', 'select', { fields });
return await withErrorHandling(async () => {
const users = await couchdbService.find({
selector: { type: "user" },
fields: fields
});
return users.map(user => new User(user));
}, errorContext);
}
}
// Add select method to instance for chaining
User.prototype.select = function(fields) {
const obj = this.toJSON();
const selected = {};
if (fields.includes('-password')) {
// Exclude password
fields = fields.filter(f => f !== '-password');
fields.forEach(field => {
if (obj[field] !== undefined) {
selected[field] = obj[field];
}
});
} else {
// Include only specified fields
fields.forEach(field => {
if (obj[field] !== undefined) {
selected[field] = obj[field];
}
});
}
return selected;
};
module.exports = User;
+547
View File
@@ -0,0 +1,547 @@
const express = require("express");
const auth = require("../middleware/auth");
const { asyncHandler } = require("../middleware/errorHandler");
const { getCacheMiddleware, invalidateCacheByPattern } = require("../middleware/cache");
const couchdbService = require("../services/couchdbService");
const router = express.Router();
/**
* Parse timeframe parameter to date filter
*/
const getTimeframeFilter = (timeframe = "all") => {
const now = new Date();
let startDate = null;
switch (timeframe) {
case "7d":
startDate = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
break;
case "30d":
startDate = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
break;
case "90d":
startDate = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000);
break;
case "all":
default:
return null;
}
return startDate ? startDate.toISOString() : null;
};
/**
* Group data by time period (day, week, month)
*/
const groupByTimePeriod = (data, groupBy = "day", dateField = "createdAt") => {
const grouped = {};
data.forEach((item) => {
const date = new Date(item[dateField]);
let key;
switch (groupBy) {
case "week":
const weekStart = new Date(date);
weekStart.setDate(date.getDate() - date.getDay());
key = weekStart.toISOString().split("T")[0];
break;
case "month":
key = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`;
break;
case "day":
default:
key = date.toISOString().split("T")[0];
break;
}
if (!grouped[key]) {
grouped[key] = [];
}
grouped[key].push(item);
});
return Object.keys(grouped)
.sort()
.map((key) => ({
period: key,
count: grouped[key].length,
items: grouped[key],
}));
};
/**
* GET /api/analytics/overview
* Get overall platform statistics
*/
router.get(
"/overview",
auth,
getCacheMiddleware(300), // Cache for 5 minutes
asyncHandler(async (req, res) => {
const { timeframe = "all" } = req.query;
const startDate = getTimeframeFilter(timeframe);
// Build queries
const userQuery = { selector: { type: "user" } };
const streetQuery = { selector: { type: "street" } };
const taskQuery = { selector: { type: "task" } };
const eventQuery = { selector: { type: "event" } };
const postQuery = { selector: { type: "post" } };
// Add timeframe filters if specified
if (startDate) {
taskQuery.selector.createdAt = { $gte: startDate };
eventQuery.selector.createdAt = { $gte: startDate };
postQuery.selector.createdAt = { $gte: startDate };
}
// Execute queries in parallel
const [users, streets, tasks, events, posts] = await Promise.all([
couchdbService.find(userQuery),
couchdbService.find(streetQuery),
couchdbService.find(taskQuery),
couchdbService.find(eventQuery),
couchdbService.find(postQuery),
]);
// Calculate statistics
const adoptedStreets = streets.filter((s) => s.status === "adopted").length;
const completedTasks = tasks.filter((t) => t.status === "completed").length;
const activeEvents = events.filter((e) => e.status === "upcoming").length;
const totalPoints = users.reduce((sum, user) => sum + (user.points || 0), 0);
const averagePointsPerUser = users.length > 0 ? Math.round(totalPoints / users.length) : 0;
res.json({
overview: {
totalUsers: users.length,
totalStreets: streets.length,
adoptedStreets,
availableStreets: streets.length - adoptedStreets,
totalTasks: tasks.length,
completedTasks,
pendingTasks: tasks.length - completedTasks,
totalEvents: events.length,
activeEvents,
completedEvents: events.filter((e) => e.status === "completed").length,
totalPosts: posts.length,
totalPoints,
averagePointsPerUser,
},
timeframe,
});
}),
);
/**
* GET /api/analytics/user/:userId
* Get user-specific analytics
*/
router.get(
"/user/:userId",
auth,
getCacheMiddleware(300), // Cache for 5 minutes
asyncHandler(async (req, res) => {
const { userId } = req.params;
const { timeframe = "all" } = req.query;
const startDate = getTimeframeFilter(timeframe);
// Get user
const user = await couchdbService.findUserById(userId);
if (!user) {
return res.status(404).json({ msg: "User not found" });
}
// Build queries for user's activity
const taskQuery = {
selector: {
type: "task",
"completedBy.userId": userId,
},
};
const postQuery = {
selector: {
type: "post",
"user.userId": userId,
},
};
const eventQuery = {
selector: {
type: "event",
participants: {
$elemMatch: { userId: userId },
},
},
};
const transactionQuery = {
selector: {
type: "point_transaction",
"user.userId": userId,
},
};
// Add timeframe filters if specified
if (startDate) {
taskQuery.selector.createdAt = { $gte: startDate };
postQuery.selector.createdAt = { $gte: startDate };
eventQuery.selector.createdAt = { $gte: startDate };
transactionQuery.selector.createdAt = { $gte: startDate };
}
// Execute queries in parallel
const [tasks, posts, events, transactions] = await Promise.all([
couchdbService.find(taskQuery),
couchdbService.find(postQuery),
couchdbService.find(eventQuery),
couchdbService.find(transactionQuery),
]);
// Get adopted streets
const adoptedStreetsDetails = await Promise.all(
(user.adoptedStreets || []).map((streetId) => couchdbService.getDocument(streetId)),
);
// Calculate points earned/spent
const pointsEarned = transactions
.filter((t) => t.amount > 0)
.reduce((sum, t) => sum + t.amount, 0);
const pointsSpent = transactions
.filter((t) => t.amount < 0)
.reduce((sum, t) => sum + Math.abs(t.amount), 0);
// Calculate engagement metrics
const totalLikesReceived = posts.reduce((sum, post) => sum + (post.likesCount || 0), 0);
const totalCommentsReceived = posts.reduce((sum, post) => sum + (post.commentsCount || 0), 0);
res.json({
user: {
id: user._id,
name: user.name,
email: user.email,
points: user.points || 0,
isPremium: user.isPremium || false,
},
stats: {
streetsAdopted: adoptedStreetsDetails.filter(Boolean).length,
tasksCompleted: tasks.length,
postsCreated: posts.length,
eventsParticipated: events.length,
badgesEarned: (user.earnedBadges || []).length,
pointsEarned,
pointsSpent,
totalLikesReceived,
totalCommentsReceived,
},
recentActivity: {
tasks: tasks.slice(0, 5),
posts: posts.slice(0, 5),
events: events.slice(0, 5),
},
timeframe,
});
}),
);
/**
* GET /api/analytics/activity
* Get activity over time
*/
router.get(
"/activity",
auth,
getCacheMiddleware(300), // Cache for 5 minutes
asyncHandler(async (req, res) => {
const { timeframe = "30d", groupBy = "day" } = req.query;
const startDate = getTimeframeFilter(timeframe);
// Build queries
const taskQuery = { selector: { type: "task" } };
const postQuery = { selector: { type: "post" } };
const eventQuery = { selector: { type: "event" } };
const streetQuery = { selector: { type: "street", status: "adopted" } };
// Add timeframe filters
if (startDate) {
taskQuery.selector.createdAt = { $gte: startDate };
postQuery.selector.createdAt = { $gte: startDate };
eventQuery.selector.createdAt = { $gte: startDate };
streetQuery.selector["adoptedBy.userId"] = { $exists: true };
}
// Execute queries in parallel
const [tasks, posts, events, streets] = await Promise.all([
couchdbService.find(taskQuery),
couchdbService.find(postQuery),
couchdbService.find(eventQuery),
couchdbService.find(streetQuery),
]);
// Filter by timeframe
const filterByTimeframe = (items) => {
if (!startDate) return items;
return items.filter((item) => {
const itemDate = new Date(item.createdAt);
return itemDate >= new Date(startDate);
});
};
const filteredTasks = filterByTimeframe(tasks);
const filteredPosts = filterByTimeframe(posts);
const filteredEvents = filterByTimeframe(events);
const filteredStreets = filterByTimeframe(streets);
// Group by time period
const groupedTasks = groupByTimePeriod(filteredTasks, groupBy);
const groupedPosts = groupByTimePeriod(filteredPosts, groupBy);
const groupedEvents = groupByTimePeriod(filteredEvents, groupBy);
const groupedStreets = groupByTimePeriod(filteredStreets, groupBy);
// Combine all periods
const allPeriods = new Set([
...groupedTasks.map((g) => g.period),
...groupedPosts.map((g) => g.period),
...groupedEvents.map((g) => g.period),
...groupedStreets.map((g) => g.period),
]);
const activityData = Array.from(allPeriods)
.sort()
.map((period) => ({
period,
tasks: groupedTasks.find((g) => g.period === period)?.count || 0,
posts: groupedPosts.find((g) => g.period === period)?.count || 0,
events: groupedEvents.find((g) => g.period === period)?.count || 0,
streetsAdopted: groupedStreets.find((g) => g.period === period)?.count || 0,
}));
res.json({
activity: activityData,
timeframe,
groupBy,
summary: {
totalTasks: filteredTasks.length,
totalPosts: filteredPosts.length,
totalEvents: filteredEvents.length,
totalStreetsAdopted: filteredStreets.length,
},
});
}),
);
/**
* GET /api/analytics/top-contributors
* Get top contributing users
*/
router.get(
"/top-contributors",
auth,
getCacheMiddleware(300), // Cache for 5 minutes
asyncHandler(async (req, res) => {
const { limit = 10, timeframe = "all", metric = "points" } = req.query;
const startDate = getTimeframeFilter(timeframe);
// Get all users
const users = await couchdbService.find({
selector: { type: "user" },
});
// If timeframe is specified, calculate contributions within that timeframe
let contributors;
if (startDate && metric !== "points") {
// For time-based metrics, query activities
const contributorsWithActivity = await Promise.all(
users.map(async (user) => {
const taskQuery = {
selector: {
type: "task",
"completedBy.userId": user._id,
createdAt: { $gte: startDate },
},
};
const postQuery = {
selector: {
type: "post",
"user.userId": user._id,
createdAt: { $gte: startDate },
},
};
const streetQuery = {
selector: {
type: "street",
"adoptedBy.userId": user._id,
},
};
const [tasks, posts, streets] = await Promise.all([
couchdbService.find(taskQuery),
couchdbService.find(postQuery),
couchdbService.find(streetQuery),
]);
let score = 0;
switch (metric) {
case "tasks":
score = tasks.length;
break;
case "posts":
score = posts.length;
break;
case "streets":
score = streets.length;
break;
default:
score = user.points || 0;
}
return {
userId: user._id,
name: user.name,
email: user.email,
profilePicture: user.profilePicture,
isPremium: user.isPremium,
score,
stats: {
points: user.points || 0,
tasksCompleted: tasks.length,
postsCreated: posts.length,
streetsAdopted: streets.length,
badgesEarned: (user.earnedBadges || []).length,
},
};
}),
);
contributors = contributorsWithActivity
.filter((c) => c.score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, parseInt(limit));
} else {
// For all-time or points metric, use user data directly
contributors = users
.map((user) => {
let score = 0;
switch (metric) {
case "tasks":
score = user.stats?.tasksCompleted || 0;
break;
case "posts":
score = user.stats?.postsCreated || 0;
break;
case "streets":
score = user.stats?.streetsAdopted || 0;
break;
default:
score = user.points || 0;
}
return {
userId: user._id,
name: user.name,
email: user.email,
profilePicture: user.profilePicture,
isPremium: user.isPremium,
score,
stats: {
points: user.points || 0,
tasksCompleted: user.stats?.tasksCompleted || 0,
postsCreated: user.stats?.postsCreated || 0,
streetsAdopted: user.stats?.streetsAdopted || 0,
badgesEarned: (user.earnedBadges || []).length,
},
};
})
.filter((c) => c.score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, parseInt(limit));
}
res.json({
contributors,
metric,
timeframe,
limit: parseInt(limit),
});
}),
);
/**
* GET /api/analytics/street-stats
* Get street adoption and task completion statistics
*/
router.get(
"/street-stats",
auth,
getCacheMiddleware(300), // Cache for 5 minutes
asyncHandler(async (req, res) => {
const { timeframe = "all" } = req.query;
const startDate = getTimeframeFilter(timeframe);
// Get all streets
const streets = await couchdbService.find({
selector: { type: "street" },
});
// Get all tasks
const taskQuery = { selector: { type: "task" } };
if (startDate) {
taskQuery.selector.createdAt = { $gte: startDate };
}
const tasks = await couchdbService.find(taskQuery);
// Calculate street statistics
const totalStreets = streets.length;
const adoptedStreets = streets.filter((s) => s.status === "adopted").length;
const availableStreets = streets.filter((s) => s.status === "available").length;
const adoptionRate = totalStreets > 0 ? ((adoptedStreets / totalStreets) * 100).toFixed(2) : 0;
// Task statistics
const totalTasks = tasks.length;
const completedTasks = tasks.filter((t) => t.status === "completed").length;
const pendingTasks = tasks.filter((t) => t.status === "pending").length;
const inProgressTasks = tasks.filter((t) => t.status === "in_progress").length;
const completionRate = totalTasks > 0 ? ((completedTasks / totalTasks) * 100).toFixed(2) : 0;
// Top streets by task completion
const streetTaskCounts = {};
tasks
.filter((t) => t.status === "completed" && t.street?.streetId)
.forEach((task) => {
const streetId = task.street.streetId;
if (!streetTaskCounts[streetId]) {
streetTaskCounts[streetId] = {
streetId,
streetName: task.street.name,
count: 0,
};
}
streetTaskCounts[streetId].count++;
});
const topStreets = Object.values(streetTaskCounts)
.sort((a, b) => b.count - a.count)
.slice(0, 10);
res.json({
adoption: {
totalStreets,
adoptedStreets,
availableStreets,
adoptionRate: parseFloat(adoptionRate),
},
tasks: {
totalTasks,
completedTasks,
pendingTasks,
inProgressTasks,
completionRate: parseFloat(completionRate),
},
topStreets,
timeframe,
});
}),
);
module.exports = router;
+8 -3
View File
@@ -4,6 +4,7 @@ const UserBadge = require("../models/UserBadge");
const auth = require("../middleware/auth");
const { asyncHandler } = require("../middleware/errorHandler");
const { getUserBadgeProgress } = require("../services/gamificationService");
const { getCacheMiddleware } = require("../middleware/cache");
const router = express.Router();
@@ -13,8 +14,9 @@ const router = express.Router();
*/
router.get(
"/",
getCacheMiddleware(600), // 10 minute cache
asyncHandler(async (req, res) => {
const badges = await Badge.find({ type: "badge" });
const badges = await Badge.findAll();
// Sort by order and rarity in JavaScript since CouchDB doesn't support complex sorting
badges.sort((a, b) => {
if (a.order !== b.order) return a.order - b.order;
@@ -31,6 +33,7 @@ router.get(
router.get(
"/progress",
auth,
getCacheMiddleware(600), // 10 minute cache
asyncHandler(async (req, res) => {
const progress = await getUserBadgeProgress(req.user.id);
res.json(progress);
@@ -38,11 +41,12 @@ router.get(
);
/**
* GET /api/badges/users/:userId
* Get badges earned by a specific user
* GET /api/users/:userId/badges
* Get badges earned by a specific user with progress
*/
router.get(
"/users/:userId",
getCacheMiddleware(600), // 10 minute cache
asyncHandler(async (req, res) => {
const { userId } = req.params;
@@ -67,6 +71,7 @@ router.get(
*/
router.get(
"/:badgeId",
getCacheMiddleware(600), // 10 minute cache
asyncHandler(async (req, res) => {
const { badgeId } = req.params;
+200
View File
@@ -0,0 +1,200 @@
const express = require("express");
const router = express.Router();
const auth = require("../middleware/auth");
const { getCacheMiddleware, invalidateCacheByPattern } = require("../middleware/cache");
const gamificationService = require("../services/gamificationService");
const User = require("../models/User");
const logger = require("../utils/logger");
/**
* @route GET /api/leaderboard/global
* @desc Get global leaderboard (all time)
* @access Public
* @query limit (default 100), offset (default 0)
*/
router.get("/global", getCacheMiddleware(300), async (req, res) => {
try {
const limit = Math.min(parseInt(req.query.limit) || 100, 500);
const offset = parseInt(req.query.offset) || 0;
logger.info("Fetching global leaderboard", { limit, offset });
const leaderboard = await gamificationService.getGlobalLeaderboard(limit, offset);
res.json({
success: true,
count: leaderboard.length,
limit,
offset,
data: leaderboard
});
} catch (error) {
logger.error("Error fetching global leaderboard", error);
res.status(500).json({
success: false,
msg: "Server error fetching global leaderboard",
error: error.message
});
}
});
/**
* @route GET /api/leaderboard/weekly
* @desc Get weekly leaderboard
* @access Public
* @query limit (default 100), offset (default 0)
*/
router.get("/weekly", getCacheMiddleware(300), async (req, res) => {
try {
const limit = Math.min(parseInt(req.query.limit) || 100, 500);
const offset = parseInt(req.query.offset) || 0;
logger.info("Fetching weekly leaderboard", { limit, offset });
const leaderboard = await gamificationService.getWeeklyLeaderboard(limit, offset);
res.json({
success: true,
count: leaderboard.length,
limit,
offset,
timeframe: "week",
data: leaderboard
});
} catch (error) {
logger.error("Error fetching weekly leaderboard", error);
res.status(500).json({
success: false,
msg: "Server error fetching weekly leaderboard",
error: error.message
});
}
});
/**
* @route GET /api/leaderboard/monthly
* @desc Get monthly leaderboard
* @access Public
* @query limit (default 100), offset (default 0)
*/
router.get("/monthly", getCacheMiddleware(300), async (req, res) => {
try {
const limit = Math.min(parseInt(req.query.limit) || 100, 500);
const offset = parseInt(req.query.offset) || 0;
logger.info("Fetching monthly leaderboard", { limit, offset });
const leaderboard = await gamificationService.getMonthlyLeaderboard(limit, offset);
res.json({
success: true,
count: leaderboard.length,
limit,
offset,
timeframe: "month",
data: leaderboard
});
} catch (error) {
logger.error("Error fetching monthly leaderboard", error);
res.status(500).json({
success: false,
msg: "Server error fetching monthly leaderboard",
error: error.message
});
}
});
/**
* @route GET /api/leaderboard/friends
* @desc Get friends leaderboard (requires auth)
* @access Private
* @query limit (default 100), offset (default 0)
*/
router.get("/friends", auth, getCacheMiddleware(300), async (req, res) => {
try {
const limit = Math.min(parseInt(req.query.limit) || 100, 500);
const offset = parseInt(req.query.offset) || 0;
const userId = req.user.id;
logger.info("Fetching friends leaderboard", { userId, limit, offset });
const leaderboard = await gamificationService.getFriendsLeaderboard(userId, limit, offset);
res.json({
success: true,
count: leaderboard.length,
limit,
offset,
data: leaderboard
});
} catch (error) {
logger.error("Error fetching friends leaderboard", error);
res.status(500).json({
success: false,
msg: "Server error fetching friends leaderboard",
error: error.message
});
}
});
/**
* @route GET /api/leaderboard/user/:userId
* @desc Get user's rank and position in leaderboard
* @access Public
*/
router.get("/user/:userId", getCacheMiddleware(300), async (req, res) => {
try {
const { userId } = req.params;
const timeframe = req.query.timeframe || "all"; // all, week, month
logger.info("Fetching user leaderboard position", { userId, timeframe });
const userPosition = await gamificationService.getUserLeaderboardPosition(userId, timeframe);
if (!userPosition) {
return res.status(404).json({
success: false,
msg: "User not found or has no points"
});
}
res.json({
success: true,
data: userPosition
});
} catch (error) {
logger.error("Error fetching user leaderboard position", error);
res.status(500).json({
success: false,
msg: "Server error fetching user position",
error: error.message
});
}
});
/**
* @route GET /api/leaderboard/stats
* @desc Get leaderboard statistics
* @access Public
*/
router.get("/stats", getCacheMiddleware(300), async (req, res) => {
try {
logger.info("Fetching leaderboard statistics");
const stats = await gamificationService.getLeaderboardStats();
res.json({
success: true,
data: stats
});
} catch (error) {
logger.error("Error fetching leaderboard statistics", error);
res.status(500).json({
success: false,
msg: "Server error fetching leaderboard statistics",
error: error.message
});
}
});
module.exports = router;
+126
View File
@@ -0,0 +1,126 @@
const express = require("express");
const User = require("../models/User");
const auth = require("../middleware/auth");
const { asyncHandler } = require("../middleware/errorHandler");
const { upload, handleUploadError } = require("../middleware/upload");
const { uploadImage, deleteImage } = require("../config/cloudinary");
const { validateProfile } = require("../middleware/validators/profileValidator");
const { userIdValidation } = require("../middleware/validators/userValidator");
const router = express.Router();
// GET user profile
router.get(
"/:userId",
auth,
userIdValidation,
asyncHandler(async (req, res) => {
const { userId } = req.params;
const user = await User.findById(userId);
if (!user) {
return res.status(404).json({ msg: "User not found" });
}
if (user.privacySettings.profileVisibility === "private" && req.user.id !== userId) {
return res.status(403).json({ msg: "This profile is private" });
}
res.json(user.toSafeObject());
})
);
// PUT update user profile
router.put(
"/",
auth,
validateProfile,
asyncHandler(async (req, res) => {
const userId = req.user.id;
const {
bio,
location,
website,
social,
privacySettings,
preferences,
} = req.body;
const user = await User.findById(userId);
if (!user) {
return res.status(404).json({ msg: "User not found" });
}
// Update fields
if (bio !== undefined) user.bio = bio;
if (location !== undefined) user.location = location;
if (website !== undefined) user.website = website;
if (social !== undefined) user.social = { ...user.social, ...social };
if (privacySettings !== undefined) user.privacySettings = { ...user.privacySettings, ...privacySettings };
if (preferences !== undefined) user.preferences = { ...user.preferences, ...preferences };
const updatedUser = await user.save();
res.json(updatedUser.toSafeObject());
})
);
// POST upload avatar
router.post(
"/avatar",
auth,
upload.single("avatar"),
handleUploadError,
asyncHandler(async (req, res) => {
if (!req.file) {
return res.status(400).json({ msg: "No image file provided" });
}
const user = await User.findById(req.user.id);
if (!user) {
return res.status(404).json({ msg: "User not found" });
}
if (user.cloudinaryPublicId) {
await deleteImage(user.cloudinaryPublicId);
}
const result = await uploadImage(
req.file.buffer,
"adopt-a-street/avatars"
);
user.avatar = result.secure_url;
user.cloudinaryPublicId = result.public_id;
const updatedUser = await user.save();
res.json({
msg: "Avatar updated successfully",
avatar: updatedUser.avatar
});
})
);
// DELETE remove avatar
router.delete(
"/avatar",
auth,
asyncHandler(async (req, res) => {
const user = await User.findById(req.user.id);
if (!user) {
return res.status(404).json({ msg: "User not found" });
}
if (user.cloudinaryPublicId) {
await deleteImage(user.cloudinaryPublicId);
user.avatar = null;
user.cloudinaryPublicId = null;
await user.save();
}
res.json({ msg: "Avatar removed successfully" });
})
);
module.exports = router;
+1 -70
View File
@@ -4,8 +4,6 @@ const Street = require("../models/Street");
const auth = require("../middleware/auth");
const { asyncHandler } = require("../middleware/errorHandler");
const { userIdValidation } = require("../middleware/validators/userValidator");
const { upload, handleUploadError } = require("../middleware/upload");
const { uploadImage, deleteImage } = require("../config/cloudinary");
const router = express.Router();
@@ -38,7 +36,7 @@ router.get(
}
const userWithStreets = {
...user,
...user.toSafeObject(),
adoptedStreets,
};
@@ -46,71 +44,4 @@ router.get(
}),
);
// Upload profile picture
router.post(
"/profile-picture",
auth,
upload.single("image"),
handleUploadError,
asyncHandler(async (req, res) => {
if (!req.file) {
return res.status(400).json({ msg: "No image file provided" });
}
const user = await User.findById(req.user.id);
if (!user) {
return res.status(404).json({ msg: "User not found" });
}
// Delete old profile picture if exists
if (user.cloudinaryPublicId) {
await deleteImage(user.cloudinaryPublicId);
}
// Upload new image to Cloudinary
const result = await uploadImage(
req.file.buffer,
"adopt-a-street/profiles",
);
// Update user with new profile picture
const updatedUser = await User.update(req.user.id, {
profilePicture: result.url,
cloudinaryPublicId: result.publicId,
});
res.json({
msg: "Profile picture updated successfully",
profilePicture: updatedUser.profilePicture,
});
}),
);
// Delete profile picture
router.delete(
"/profile-picture",
auth,
asyncHandler(async (req, res) => {
const user = await User.findById(req.user.id);
if (!user) {
return res.status(404).json({ msg: "User not found" });
}
if (!user.cloudinaryPublicId) {
return res.status(400).json({ msg: "No profile picture to delete" });
}
// Delete image from Cloudinary
await deleteImage(user.cloudinaryPublicId);
// Remove from user
await User.update(req.user.id, {
profilePicture: undefined,
cloudinaryPublicId: undefined,
});
res.json({ msg: "Profile picture deleted successfully" });
}),
);
module.exports = router;
+3
View File
@@ -137,6 +137,7 @@ const aiRoutes = require("./routes/ai");
const paymentRoutes = require("./routes/payments");
const userRoutes = require("./routes/users");
const cacheRoutes = require("./routes/cache");
const profileRoutes = require("./routes/profile");
// Apply rate limiters
app.use("/api/auth/register", authLimiter);
@@ -238,6 +239,8 @@ app.use("/api/ai", aiRoutes);
app.use("/api/payments", paymentRoutes);
app.use("/api/users", userRoutes);
app.use("/api/cache", cacheRoutes);
app.use("/api/analytics", analyticsRoutes);
app.use("/api/leaderboard", leaderboardRoutes);
app.get("/", (req, res) => {
res.send("Street Adoption App Backend");
+544 -17
View File
@@ -113,14 +113,14 @@ async function checkAndAwardBadges(userId, userPoints = null) {
// Check each badge criteria
for (const badge of allBadges) {
// Skip if user already has this badge
if (userBadges.some(ub => ub.badgeId === badge._id)) {
if (userBadges.some(ub => ub.badge?._id === badge._id || ub.badge === badge._id)) {
continue;
}
let qualifies = false;
// Check different badge criteria
switch (badge.criteria.type) {
switch (badge.criteria?.type) {
case 'points_earned':
qualifies = userPoints >= badge.criteria.threshold;
break;
@@ -128,13 +128,13 @@ async function checkAndAwardBadges(userId, userPoints = null) {
qualifies = userStats.streetAdoptions >= badge.criteria.threshold;
break;
case 'task_completions':
qualifies = userStats.taskCompletions >= badge.criteria.threshold;
qualifies = userStats.tasksCompleted >= badge.criteria.threshold;
break;
case 'post_creations':
qualifies = userStats.postCreations >= badge.criteria.threshold;
qualifies = userStats.postsCreated >= badge.criteria.threshold;
break;
case 'event_participations':
qualifies = userStats.eventParticipations >= badge.criteria.threshold;
qualifies = userStats.eventsParticipated >= badge.criteria.threshold;
break;
case 'consecutive_days':
qualifies = userStats.consecutiveDays >= badge.criteria.threshold;
@@ -168,9 +168,9 @@ async function awardBadge(userId, badgeId) {
// Create user badge record
const userBadge = await UserBadge.create({
userId: userId,
badgeId: badgeId,
awardedAt: new Date().toISOString(),
user: userId,
badge: badgeId,
earnedAt: new Date().toISOString(),
});
// Award points for earning badge (if it's a rare or higher badge)
@@ -205,16 +205,19 @@ async function awardBadge(userId, badgeId) {
*/
async function getUserStats(userId) {
try {
// This would typically involve querying various collections
// For now, return basic stats - this should be enhanced
const user = await User.findById(userId);
if (!user) {
throw new Error("User not found");
}
return {
streetAdoptions: 0, // Would query Street collection
taskCompletions: 0, // Would query Task collection
postCreations: 0, // Would query Post collection
eventParticipations: 0, // Would query Event participation
consecutiveDays: 0, // Would calculate from login history
points: user.points || 0,
streetsAdopted: user.stats?.streetsAdopted || 0,
tasksCompleted: user.stats?.tasksCompleted || 0,
postsCreated: user.stats?.postsCreated || 0,
eventsParticipated: user.stats?.eventsParticipated || 0,
badgesEarned: user.stats?.badgesEarned || 0,
consecutiveDays: user.stats?.consecutiveDays || 0,
};
} catch (error) {
console.error("Error getting user stats:", error);
@@ -222,6 +225,71 @@ async function getUserStats(userId) {
}
}
/**
* Get user's badge progress for all badges (earned and unearned)
*/
async function getUserBadgeProgress(userId) {
try {
const allBadges = await Badge.findAll();
const userStats = await getUserStats(userId);
const userEarnedBadges = await UserBadge.findByUser(userId);
const badgeProgress = allBadges.map(badge => {
const earnedBadge = userEarnedBadges.find(ub => ub.badge?._id === badge._id || ub.badge === badge._id);
const isEarned = !!earnedBadge;
let progress = 0;
let threshold = badge.criteria?.threshold || 0;
if (isEarned) {
progress = threshold; // If earned, progress is full
} else if (badge.criteria?.type) {
switch (badge.criteria.type) {
case 'points_earned':
progress = userStats.points || 0;
break;
case 'street_adoptions':
progress = userStats.streetsAdopted;
break;
case 'task_completions':
progress = userStats.tasksCompleted;
break;
case 'post_creations':
progress = userStats.postsCreated;
break;
case 'event_participations':
progress = userStats.eventsParticipated;
break;
case 'consecutive_days':
progress = userStats.consecutiveDays;
break;
case 'special':
progress = 0; // Special badges have no progress bar
threshold = 1;
break;
default:
progress = 0;
}
}
// Ensure progress doesn't exceed threshold
progress = Math.min(progress, threshold);
return {
...badge,
isEarned,
progress,
threshold,
earnedAt: isEarned ? earnedBadge.earnedAt : null,
};
});
return badgeProgress;
} catch (error) {
console.error("Error getting user badge progress:", error);
throw error;
}
}
/**
* Get user's badges
*/
@@ -231,11 +299,13 @@ async function getUserBadges(userId) {
const badges = [];
for (const userBadge of userBadges) {
const badge = await Badge.findById(userBadge.badgeId);
const badgeData = userBadge.badge;
// If badge is already populated (object), use it; otherwise fetch it
const badge = typeof badgeData === 'object' && badgeData._id ? badgeData : await Badge.findById(badgeData);
if (badge) {
badges.push({
...badge,
awardedAt: userBadge.awardedAt,
earnedAt: userBadge.earnedAt,
});
}
}
@@ -301,6 +371,455 @@ async function getLeaderboard(limit = 10) {
}
}
/**
* Get global leaderboard (all time)
* @param {number} limit - Number of users to return
* @param {number} offset - Offset for pagination
* @returns {Promise<Array>} Leaderboard data
*/
async function getGlobalLeaderboard(limit = 100, offset = 0) {
try {
const couchdbService = require("./couchdbService");
const Street = require("../models/Street");
const Task = require("../models/Task");
// Get all users sorted by points
const result = await couchdbService.find({
selector: {
type: "user",
points: { $gt: 0 }
},
sort: [{ points: "desc" }],
limit: limit,
skip: offset
});
// Enrich with stats and badges
const leaderboard = await Promise.all(result.map(async (user, index) => {
// Get user badges
const userBadges = await UserBadge.findByUser(user._id);
const badges = await Promise.all(userBadges.map(async (ub) => {
const badgeData = ub.badge;
const badge = typeof badgeData === 'object' && badgeData._id ? badgeData : await Badge.findById(badgeData);
return badge ? {
_id: badge._id,
name: badge.name,
icon: badge.icon,
rarity: badge.rarity
} : null;
}));
return {
rank: offset + index + 1,
userId: user._id,
username: user.name,
avatar: user.profilePicture || null,
points: user.points || 0,
streetsAdopted: user.stats?.streetsAdopted || user.adoptedStreets?.length || 0,
tasksCompleted: user.stats?.tasksCompleted || user.completedTasks?.length || 0,
badges: badges.filter(b => b !== null)
};
}));
return leaderboard;
} catch (error) {
console.error("Error getting global leaderboard:", error);
throw error;
}
}
/**
* Get weekly leaderboard
* @param {number} limit - Number of users to return
* @param {number} offset - Offset for pagination
* @returns {Promise<Array>} Leaderboard data
*/
async function getWeeklyLeaderboard(limit = 100, offset = 0) {
try {
const couchdbService = require("./couchdbService");
// Calculate start of week (Monday 00:00:00)
const now = new Date();
const dayOfWeek = now.getDay();
const daysToMonday = dayOfWeek === 0 ? 6 : dayOfWeek - 1;
const startOfWeek = new Date(now);
startOfWeek.setDate(now.getDate() - daysToMonday);
startOfWeek.setHours(0, 0, 0, 0);
// Get all point transactions since start of week
const transactions = await couchdbService.find({
selector: {
type: "point_transaction",
createdAt: { $gte: startOfWeek.toISOString() }
}
});
// Aggregate points by user
const userPointsMap = {};
transactions.forEach(transaction => {
if (!userPointsMap[transaction.user]) {
userPointsMap[transaction.user] = 0;
}
userPointsMap[transaction.user] += transaction.amount;
});
// Convert to array and sort
const userPoints = Object.entries(userPointsMap)
.map(([userId, points]) => ({ userId, points }))
.filter(entry => entry.points > 0)
.sort((a, b) => b.points - a.points)
.slice(offset, offset + limit);
// Enrich with user data
const leaderboard = await Promise.all(userPoints.map(async (entry, index) => {
const user = await User.findById(entry.userId);
if (!user) return null;
// Get user badges
const userBadges = await UserBadge.findByUser(userId);
const badges = await Promise.all(userBadges.map(async (ub) => {
const badgeData = ub.badge;
const badge = typeof badgeData === 'object' && badgeData._id ? badgeData : await Badge.findById(badgeData);
return badge ? {
_id: badge._id,
name: badge.name,
icon: badge.icon,
rarity: badge.rarity
} : null;
}));
return {
rank: offset + index + 1,
userId: user._id,
username: user.name,
avatar: user.profilePicture || null,
points: entry.points,
streetsAdopted: user.stats?.streetsAdopted || user.adoptedStreets?.length || 0,
tasksCompleted: user.stats?.tasksCompleted || user.completedTasks?.length || 0,
badges: badges.filter(b => b !== null)
};
}));
return leaderboard.filter(entry => entry !== null);
} catch (error) {
console.error("Error getting weekly leaderboard:", error);
throw error;
}
}
/**
* Get monthly leaderboard
* @param {number} limit - Number of users to return
* @param {number} offset - Offset for pagination
* @returns {Promise<Array>} Leaderboard data
*/
async function getMonthlyLeaderboard(limit = 100, offset = 0) {
try {
const couchdbService = require("./couchdbService");
// Calculate start of month
const now = new Date();
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
// Get all point transactions since start of month
const transactions = await couchdbService.find({
selector: {
type: "point_transaction",
createdAt: { $gte: startOfMonth.toISOString() }
}
});
// Aggregate points by user
const userPointsMap = {};
transactions.forEach(transaction => {
if (!userPointsMap[transaction.user]) {
userPointsMap[transaction.user] = 0;
}
userPointsMap[transaction.user] += transaction.amount;
});
// Convert to array and sort
const userPoints = Object.entries(userPointsMap)
.map(([userId, points]) => ({ userId, points }))
.filter(entry => entry.points > 0)
.sort((a, b) => b.points - a.points)
.slice(offset, offset + limit);
// Enrich with user data
const leaderboard = await Promise.all(userPoints.map(async (entry, index) => {
const user = await User.findById(entry.userId);
if (!user) return null;
// Get user badges
const userBadges = await UserBadge.findByUser(user._id);
const badges = await Promise.all(userBadges.slice(0, 5).map(async (ub) => {
const badgeData = ub.badge;
const badge = typeof badgeData === 'object' && badgeData._id ? badgeData : await Badge.findById(badgeData);
return badge ? {
_id: badge._id,
name: badge.name,
icon: badge.icon,
rarity: badge.rarity
} : null;
}));
return {
rank: offset + index + 1,
userId: user._id,
username: user.name,
avatar: user.profilePicture || null,
points: entry.points,
streetsAdopted: user.stats?.streetsAdopted || user.adoptedStreets?.length || 0,
tasksCompleted: user.stats?.tasksCompleted || user.completedTasks?.length || 0,
badges: badges.filter(b => b !== null)
};
}));
return leaderboard.filter(entry => entry !== null);
} catch (error) {
console.error("Error getting monthly leaderboard:", error);
throw error;
}
}
/**
* Get friends leaderboard
* @param {string} userId - User ID
* @param {number} limit - Number of users to return
* @param {number} offset - Offset for pagination
* @returns {Promise<Array>} Leaderboard data
*/
async function getFriendsLeaderboard(userId, limit = 100, offset = 0) {
try {
const user = await User.findById(userId);
if (!user) {
throw new Error("User not found");
}
// For now, return empty array as friends system isn't implemented
// In future, would get user's friends list and filter leaderboard
const friendIds = user.friends || [];
if (friendIds.length === 0) {
// Include self if no friends
friendIds.push(userId);
}
const couchdbService = require("./couchdbService");
// Get friends' data
const friends = await couchdbService.find({
selector: {
type: "user",
_id: { $in: friendIds }
}
});
// Sort by points
const sortedFriends = friends
.sort((a, b) => (b.points || 0) - (a.points || 0))
.slice(offset, offset + limit);
// Enrich with badges
const leaderboard = await Promise.all(sortedFriends.map(async (friend, index) => {
const userBadges = await UserBadge.findByUser(friend._id);
const badges = await Promise.all(userBadges.slice(0, 5).map(async (ub) => {
const badgeData = ub.badge;
const badge = typeof badgeData === 'object' && badgeData._id ? badgeData : await Badge.findById(badgeData);
return badge ? {
_id: badge._id,
name: badge.name,
icon: badge.icon,
rarity: badge.rarity
} : null;
}));
return {
rank: offset + index + 1,
userId: friend._id,
username: friend.name,
avatar: friend.profilePicture || null,
points: friend.points || 0,
streetsAdopted: friend.stats?.streetsAdopted || friend.adoptedStreets?.length || 0,
tasksCompleted: friend.stats?.tasksCompleted || friend.completedTasks?.length || 0,
badges: badges.filter(b => b !== null),
isFriend: true
};
}));
return leaderboard;
} catch (error) {
console.error("Error getting friends leaderboard:", error);
throw error;
}
}
/**
* Get user's leaderboard position
* @param {string} userId - User ID
* @param {string} timeframe - 'all', 'week', or 'month'
* @returns {Promise<Object>} User's position data
*/
async function getUserLeaderboardPosition(userId, timeframe = "all") {
try {
const user = await User.findById(userId);
if (!user) {
return null;
}
let rank = 0;
let totalUsers = 0;
let userPoints = 0;
const couchdbService = require("./couchdbService");
if (timeframe === "all") {
// Get all users with points
const allUsers = await couchdbService.find({
selector: {
type: "user",
points: { $gt: 0 }
},
sort: [{ points: "desc" }]
});
totalUsers = allUsers.length;
userPoints = user.points || 0;
// Find user's rank
rank = allUsers.findIndex(u => u._id === userId) + 1;
} else if (timeframe === "week" || timeframe === "month") {
// Calculate start date
const now = new Date();
let startDate;
if (timeframe === "week") {
const dayOfWeek = now.getDay();
const daysToMonday = dayOfWeek === 0 ? 6 : dayOfWeek - 1;
startDate = new Date(now);
startDate.setDate(now.getDate() - daysToMonday);
startDate.setHours(0, 0, 0, 0);
} else {
startDate = new Date(now.getFullYear(), now.getMonth(), 1);
}
// Get all transactions for period
const transactions = await couchdbService.find({
selector: {
type: "point_transaction",
createdAt: { $gte: startDate.toISOString() }
}
});
// Aggregate points by user
const userPointsMap = {};
transactions.forEach(transaction => {
if (!userPointsMap[transaction.user]) {
userPointsMap[transaction.user] = 0;
}
userPointsMap[transaction.user] += transaction.amount;
});
// Sort users by points
const sortedUsers = Object.entries(userPointsMap)
.filter(([_, points]) => points > 0)
.sort((a, b) => b[1] - a[1]);
totalUsers = sortedUsers.length;
userPoints = userPointsMap[userId] || 0;
rank = sortedUsers.findIndex(([id, _]) => id === userId) + 1;
}
// Get user badges
const userBadges = await UserBadge.findByUser(user._id);
const badges = await Promise.all(userBadges.slice(0, 5).map(async (ub) => {
const badgeData = ub.badge;
const badge = typeof badgeData === 'object' && badgeData._id ? badgeData : await Badge.findById(badgeData);
return badge ? {
_id: badge._id,
name: badge.name,
icon: badge.icon,
rarity: badge.rarity
} : null;
}));
return {
rank: rank || null,
totalUsers,
userId: user._id,
username: user.name,
avatar: user.profilePicture || null,
points: userPoints,
streetsAdopted: user.stats?.streetsAdopted || user.adoptedStreets?.length || 0,
tasksCompleted: user.stats?.tasksCompleted || user.completedTasks?.length || 0,
badges: badges.filter(b => b !== null),
percentile: totalUsers > 0 ? Math.round((1 - (rank - 1) / totalUsers) * 100) : 0
};
} catch (error) {
console.error("Error getting user leaderboard position:", error);
throw error;
}
}
/**
* Get leaderboard statistics
* @returns {Promise<Object>} Statistics data
*/
async function getLeaderboardStats() {
try {
const couchdbService = require("./couchdbService");
// Get all users with points
const allUsers = await couchdbService.find({
selector: {
type: "user",
points: { $gt: 0 }
}
});
// Calculate statistics
const totalUsers = allUsers.length;
const totalPoints = allUsers.reduce((sum, user) => sum + (user.points || 0), 0);
const avgPoints = totalUsers > 0 ? Math.round(totalPoints / totalUsers) : 0;
const maxPoints = allUsers.length > 0 ? Math.max(...allUsers.map(u => u.points || 0)) : 0;
const minPoints = allUsers.length > 0 ? Math.min(...allUsers.map(u => u.points || 0)) : 0;
// Get weekly stats
const now = new Date();
const dayOfWeek = now.getDay();
const daysToMonday = dayOfWeek === 0 ? 6 : dayOfWeek - 1;
const startOfWeek = new Date(now);
startOfWeek.setDate(now.getDate() - daysToMonday);
startOfWeek.setHours(0, 0, 0, 0);
const weeklyTransactions = await couchdbService.find({
selector: {
type: "point_transaction",
createdAt: { $gte: startOfWeek.toISOString() }
}
});
const weeklyPoints = weeklyTransactions.reduce((sum, t) => sum + (t.amount || 0), 0);
const activeUsersThisWeek = new Set(weeklyTransactions.map(t => t.user)).size;
return {
totalUsers,
totalPoints,
avgPoints,
maxPoints,
minPoints,
weeklyStats: {
totalPoints: weeklyPoints,
activeUsers: activeUsersThisWeek,
transactions: weeklyTransactions.length
}
};
} catch (error) {
console.error("Error getting leaderboard statistics:", error);
throw error;
}
}
module.exports = {
awardPoints,
getUserPoints,
@@ -308,7 +827,15 @@ module.exports = {
checkAndAwardBadges,
awardBadge,
getUserBadges,
getUserBadgeProgress,
getUserStats,
redeemPoints,
getLeaderboard,
getGlobalLeaderboard,
getWeeklyLeaderboard,
getMonthlyLeaderboard,
getFriendsLeaderboard,
getUserLeaderboardPosition,
getLeaderboardStats,
POINT_VALUES,
};