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

547
backend/routes/analytics.js Normal file
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;

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;

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
backend/routes/profile.js Normal file
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;

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;