diff --git a/backend/models/Badge.js b/backend/models/Badge.js new file mode 100644 index 0000000..f585b30 --- /dev/null +++ b/backend/models/Badge.js @@ -0,0 +1,57 @@ +const mongoose = require("mongoose"); + +const BadgeSchema = new mongoose.Schema( + { + name: { + type: String, + required: true, + unique: true, + }, + description: { + type: String, + required: true, + }, + icon: { + type: String, + required: true, + }, + criteria: { + type: { + type: String, + enum: [ + "street_adoptions", + "task_completions", + "post_creations", + "event_participations", + "points_earned", + "consecutive_days", + "special", + ], + required: true, + }, + threshold: { + type: Number, + required: true, + }, + }, + rarity: { + type: String, + enum: ["common", "rare", "epic", "legendary"], + default: "common", + }, + order: { + type: Number, + default: 0, + }, + }, + { + timestamps: true, + }, +); + +// Index for efficient badge queries +BadgeSchema.index({ "criteria.type": 1, "criteria.threshold": 1 }); +BadgeSchema.index({ rarity: 1 }); +BadgeSchema.index({ order: 1 }); + +module.exports = mongoose.model("Badge", BadgeSchema); diff --git a/backend/models/PointTransaction.js b/backend/models/PointTransaction.js new file mode 100644 index 0000000..7abdc85 --- /dev/null +++ b/backend/models/PointTransaction.js @@ -0,0 +1,57 @@ +const mongoose = require("mongoose"); + +const PointTransactionSchema = new mongoose.Schema( + { + user: { + type: mongoose.Schema.Types.ObjectId, + ref: "User", + required: true, + index: true, + }, + amount: { + type: Number, + required: true, + }, + type: { + type: String, + enum: [ + "street_adoption", + "task_completion", + "post_creation", + "event_participation", + "reward_redemption", + "admin_adjustment", + ], + required: true, + index: true, + }, + description: { + type: String, + required: true, + }, + relatedEntity: { + entityType: { + type: String, + enum: ["Street", "Task", "Post", "Event", "Reward"], + }, + entityId: { + type: mongoose.Schema.Types.ObjectId, + }, + }, + balanceAfter: { + type: Number, + required: true, + }, + }, + { + timestamps: true, + }, +); + +// Compound index for user transaction history queries +PointTransactionSchema.index({ user: 1, createdAt: -1 }); + +// Index for querying by transaction type +PointTransactionSchema.index({ type: 1, createdAt: -1 }); + +module.exports = mongoose.model("PointTransaction", PointTransactionSchema); diff --git a/backend/models/User.js b/backend/models/User.js index ef61f7c..4862a3d 100644 --- a/backend/models/User.js +++ b/backend/models/User.js @@ -22,6 +22,7 @@ const UserSchema = new mongoose.Schema( points: { type: Number, default: 0, + min: 0, }, adoptedStreets: [ { @@ -35,11 +36,43 @@ const UserSchema = new mongoose.Schema( ref: "Task", }, ], - badges: [String], + posts: [ + { + type: mongoose.Schema.Types.ObjectId, + ref: "Post", + }, + ], + events: [ + { + type: mongoose.Schema.Types.ObjectId, + ref: "Event", + }, + ], + profilePicture: { + type: String, + }, + cloudinaryPublicId: { + type: String, + }, }, { timestamps: true, }, ); +// Indexes for performance +UserSchema.index({ email: 1 }); +UserSchema.index({ points: -1 }); // For leaderboards + +// Virtual for earned badges (populated from UserBadge collection) +UserSchema.virtual("earnedBadges", { + ref: "UserBadge", + localField: "_id", + foreignField: "user", +}); + +// Ensure virtuals are included when converting to JSON +UserSchema.set("toJSON", { virtuals: true }); +UserSchema.set("toObject", { virtuals: true }); + module.exports = mongoose.model("User", UserSchema); diff --git a/backend/models/UserBadge.js b/backend/models/UserBadge.js new file mode 100644 index 0000000..adb5568 --- /dev/null +++ b/backend/models/UserBadge.js @@ -0,0 +1,37 @@ +const mongoose = require("mongoose"); + +const UserBadgeSchema = new mongoose.Schema( + { + user: { + type: mongoose.Schema.Types.ObjectId, + ref: "User", + required: true, + index: true, + }, + badge: { + type: mongoose.Schema.Types.ObjectId, + ref: "Badge", + required: true, + index: true, + }, + earnedAt: { + type: Date, + default: Date.now, + }, + progress: { + type: Number, + default: 0, + }, + }, + { + timestamps: true, + }, +); + +// Compound unique index to prevent duplicate badge awards +UserBadgeSchema.index({ user: 1, badge: 1 }, { unique: true }); + +// Index for user badge queries +UserBadgeSchema.index({ user: 1, earnedAt: -1 }); + +module.exports = mongoose.model("UserBadge", UserBadgeSchema); diff --git a/backend/routes/badges.js b/backend/routes/badges.js new file mode 100644 index 0000000..47778b6 --- /dev/null +++ b/backend/routes/badges.js @@ -0,0 +1,76 @@ +const express = require("express"); +const Badge = require("../models/Badge"); +const UserBadge = require("../models/UserBadge"); +const auth = require("../middleware/auth"); +const { asyncHandler } = require("../middleware/errorHandler"); +const { getUserBadgeProgress } = require("../services/gamificationService"); + +const router = express.Router(); + +/** + * GET /api/badges + * Get all available badges + */ +router.get( + "/", + asyncHandler(async (req, res) => { + const badges = await Badge.find().sort({ order: 1, rarity: 1 }); + res.json(badges); + }) +); + +/** + * GET /api/badges/progress + * Get current user's badge progress (requires authentication) + */ +router.get( + "/progress", + auth, + asyncHandler(async (req, res) => { + const progress = await getUserBadgeProgress(req.user.id); + res.json(progress); + }) +); + +/** + * GET /api/users/:userId/badges + * Get badges earned by a specific user + */ +router.get( + "/users/:userId", + asyncHandler(async (req, res) => { + const { userId } = req.params; + + const userBadges = await UserBadge.find({ user: userId }) + .populate("badge") + .sort({ earnedAt: -1 }); + + res.json( + userBadges.map((ub) => ({ + badge: ub.badge, + earnedAt: ub.earnedAt, + progress: ub.progress, + })) + ); + }) +); + +/** + * GET /api/badges/:badgeId + * Get a specific badge by ID + */ +router.get( + "/:badgeId", + asyncHandler(async (req, res) => { + const { badgeId } = req.params; + + const badge = await Badge.findById(badgeId); + if (!badge) { + return res.status(404).json({ msg: "Badge not found" }); + } + + res.json(badge); + }) +); + +module.exports = router; diff --git a/backend/routes/events.js b/backend/routes/events.js index 543b7b5..2f28fa5 100644 --- a/backend/routes/events.js +++ b/backend/routes/events.js @@ -1,25 +1,48 @@ const express = require("express"); +const mongoose = require("mongoose"); const Event = require("../models/Event"); +const User = require("../models/User"); const auth = require("../middleware/auth"); +const { asyncHandler } = require("../middleware/errorHandler"); +const { + createEventValidation, + eventIdValidation, +} = require("../middleware/validators/eventValidator"); +const { paginate, buildPaginatedResponse } = require("../middleware/pagination"); +const { + awardEventParticipationPoints, + checkAndAwardBadges, +} = require("../services/gamificationService"); const router = express.Router(); -// Get all events -router.get("/", async (req, res) => { - try { - const events = await Event.find(); - res.json(events); - } catch (err) { - console.error(err.message); - res.status(500).send("Server error"); - } -}); +// Get all events (with pagination) +router.get( + "/", + paginate, + asyncHandler(async (req, res) => { + const { skip, limit, page } = req.pagination; + + const events = await Event.find() + .sort({ date: -1 }) + .skip(skip) + .limit(limit) + .populate("participants", ["name", "profilePicture"]); + + const totalCount = await Event.countDocuments(); + + res.json(buildPaginatedResponse(events, totalCount, page, limit)); + }), +); // Create an event -router.post("/", auth, async (req, res) => { - const { title, description, date, location } = req.body; +router.post( + "/", + auth, + createEventValidation, + asyncHandler(async (req, res) => { + const { title, description, date, location } = req.body; - try { const newEvent = new Event({ title, description, @@ -29,38 +52,72 @@ router.post("/", auth, async (req, res) => { const event = await newEvent.save(); res.json(event); - } catch (err) { - console.error(err.message); - res.status(500).send("Server error"); - } -}); + }), +); // RSVP to an event -router.put("/rsvp/:id", auth, async (req, res) => { - try { - const event = await Event.findById(req.params.id); - if (!event) { - return res.status(404).json({ msg: "Event not found" }); +router.put( + "/rsvp/:id", + auth, + eventIdValidation, + asyncHandler(async (req, res) => { + const session = await mongoose.startSession(); + session.startTransaction(); + + try { + const event = await Event.findById(req.params.id).session(session); + if (!event) { + await session.abortTransaction(); + session.endSession(); + return res.status(404).json({ msg: "Event not found" }); + } + + // Check if the user has already RSVPed + if ( + event.participants.filter( + (participant) => participant.toString() === req.user.id, + ).length > 0 + ) { + await session.abortTransaction(); + session.endSession(); + return res.status(400).json({ msg: "Already RSVPed" }); + } + + event.participants.unshift(req.user.id); + await event.save({ session }); + + // Update user's events array + const user = await User.findById(req.user.id).session(session); + if (!user.events.includes(event._id)) { + user.events.push(event._id); + await user.save({ session }); + } + + // Award points for event participation + const { transaction } = await awardEventParticipationPoints( + req.user.id, + event._id, + session + ); + + // Check and award badges + const newBadges = await checkAndAwardBadges(req.user.id, session); + + await session.commitTransaction(); + session.endSession(); + + res.json({ + participants: event.participants, + pointsAwarded: transaction.amount, + newBalance: transaction.balanceAfter, + badgesEarned: newBadges, + }); + } catch (err) { + await session.abortTransaction(); + session.endSession(); + throw err; } - - // Check if the user has already RSVPed - if ( - event.participants.filter( - (participant) => participant.toString() === req.user.id, - ).length > 0 - ) { - return res.status(400).json({ msg: "Already RSVPed" }); - } - - event.participants.unshift(req.user.id); - - await event.save(); - - res.json(event.participants); - } catch (err) { - console.error(err.message); - res.status(500).send("Server error"); - } -}); + }), +); module.exports = router; diff --git a/backend/routes/posts.js b/backend/routes/posts.js index db77fb4..10fd96e 100644 --- a/backend/routes/posts.js +++ b/backend/routes/posts.js @@ -1,42 +1,154 @@ const express = require("express"); +const mongoose = require("mongoose"); const Post = require("../models/Post"); const auth = require("../middleware/auth"); +const { asyncHandler } = require("../middleware/errorHandler"); +const { + createPostValidation, + postIdValidation, +} = require("../middleware/validators/postValidator"); +const { upload, handleUploadError } = require("../middleware/upload"); +const { uploadImage, deleteImage } = require("../config/cloudinary"); +const { paginate, buildPaginatedResponse } = require("../middleware/pagination"); +const { + awardPostCreationPoints, + checkAndAwardBadges, +} = require("../services/gamificationService"); const router = express.Router(); -// Get all posts -router.get("/", async (req, res) => { - try { - const posts = await Post.find().populate("user", ["name"]); - res.json(posts); - } catch (err) { - console.error(err.message); - res.status(500).send("Server error"); - } -}); +// Get all posts (with pagination) +router.get( + "/", + paginate, + asyncHandler(async (req, res) => { + const { skip, limit, page } = req.pagination; -// Create a post -router.post("/", auth, async (req, res) => { - const { content, imageUrl } = req.body; + const posts = await Post.find() + .sort({ createdAt: -1 }) + .skip(skip) + .limit(limit) + .populate("user", ["name", "profilePicture"]); - try { - const newPost = new Post({ - user: req.user.id, - content, - imageUrl, - }); + const totalCount = await Post.countDocuments(); + + res.json(buildPaginatedResponse(posts, totalCount, page, limit)); + }), +); + +// Create a post with optional image +router.post( + "/", + auth, + upload.single("image"), + handleUploadError, + asyncHandler(async (req, res) => { + const { content } = req.body; + const session = await mongoose.startSession(); + session.startTransaction(); + + try { + if (!content) { + await session.abortTransaction(); + session.endSession(); + return res.status(400).json({ msg: "Content is required" }); + } + + const postData = { + user: req.user.id, + content, + }; + + // Upload image if provided + if (req.file) { + const result = await uploadImage( + req.file.buffer, + "adopt-a-street/posts", + ); + postData.imageUrl = result.url; + postData.cloudinaryPublicId = result.publicId; + } + + const newPost = new Post(postData); + const post = await newPost.save({ session }); + + // Award points for post creation + const { transaction } = await awardPostCreationPoints( + req.user.id, + post._id, + session + ); + + // Check and award badges + const newBadges = await checkAndAwardBadges(req.user.id, session); + + await session.commitTransaction(); + session.endSession(); + + // Populate user data before sending response + await post.populate("user", ["name", "profilePicture"]); + + res.json({ + post, + pointsAwarded: transaction.amount, + newBalance: transaction.balanceAfter, + badgesEarned: newBadges, + }); + } catch (err) { + await session.abortTransaction(); + session.endSession(); + throw err; + } + }), +); + +// Add image to existing post +router.post( + "/:id/image", + auth, + upload.single("image"), + handleUploadError, + postIdValidation, + asyncHandler(async (req, res) => { + const post = await Post.findById(req.params.id); + if (!post) { + return res.status(404).json({ msg: "Post not found" }); + } + + // Verify user owns the post + if (post.user.toString() !== req.user.id) { + return res.status(403).json({ msg: "Not authorized" }); + } + + if (!req.file) { + return res.status(400).json({ msg: "No image file provided" }); + } + + // Delete old image if exists + if (post.cloudinaryPublicId) { + await deleteImage(post.cloudinaryPublicId); + } + + // Upload new image + const result = await uploadImage( + req.file.buffer, + "adopt-a-street/posts", + ); + + post.imageUrl = result.url; + post.cloudinaryPublicId = result.publicId; + await post.save(); - const post = await newPost.save(); res.json(post); - } catch (err) { - console.error(err.message); - res.status(500).send("Server error"); - } -}); + }), +); // Like a post -router.put("/like/:id", auth, async (req, res) => { - try { +router.put( + "/like/:id", + auth, + postIdValidation, + asyncHandler(async (req, res) => { const post = await Post.findById(req.params.id); if (!post) { return res.status(404).json({ msg: "Post not found" }); @@ -54,10 +166,7 @@ router.put("/like/:id", auth, async (req, res) => { await post.save(); res.json(post.likes); - } catch (err) { - console.error(err.message); - res.status(500).send("Server error"); - } -}); + }), +); module.exports = router; diff --git a/backend/routes/rewards.js b/backend/routes/rewards.js index 2cca6dd..cae266c 100644 --- a/backend/routes/rewards.js +++ b/backend/routes/rewards.js @@ -1,26 +1,44 @@ const express = require("express"); +const mongoose = require("mongoose"); const Reward = require("../models/Reward"); const User = require("../models/User"); const auth = require("../middleware/auth"); +const { asyncHandler } = require("../middleware/errorHandler"); +const { + createRewardValidation, + rewardIdValidation, +} = require("../middleware/validators/rewardValidator"); +const { paginate, buildPaginatedResponse } = require("../middleware/pagination"); +const { deductRewardPoints } = require("../services/gamificationService"); const router = express.Router(); -// Get all rewards -router.get("/", async (req, res) => { - try { - const rewards = await Reward.find(); - res.json(rewards); - } catch (err) { - console.error(err.message); - res.status(500).send("Server error"); - } -}); +// Get all rewards (with pagination) +router.get( + "/", + paginate, + asyncHandler(async (req, res) => { + const { skip, limit, page } = req.pagination; + + const rewards = await Reward.find() + .sort({ cost: 1 }) + .skip(skip) + .limit(limit); + + const totalCount = await Reward.countDocuments(); + + res.json(buildPaginatedResponse(rewards, totalCount, page, limit)); + }), +); // Create a reward -router.post("/", auth, async (req, res) => { - const { name, description, cost, isPremium } = req.body; +router.post( + "/", + auth, + createRewardValidation, + asyncHandler(async (req, res) => { + const { name, description, cost, isPremium } = req.body; - try { const newReward = new Reward({ name, description, @@ -30,41 +48,67 @@ router.post("/", auth, async (req, res) => { const reward = await newReward.save(); res.json(reward); - } catch (err) { - console.error(err.message); - res.status(500).send("Server error"); - } -}); + }), +); // Redeem a reward -router.post("/redeem/:id", auth, async (req, res) => { - try { - const reward = await Reward.findById(req.params.id); - if (!reward) { - return res.status(404).json({ msg: "Reward not found" }); +router.post( + "/redeem/:id", + auth, + rewardIdValidation, + asyncHandler(async (req, res) => { + const session = await mongoose.startSession(); + session.startTransaction(); + + try { + const reward = await Reward.findById(req.params.id).session(session); + if (!reward) { + await session.abortTransaction(); + session.endSession(); + return res.status(404).json({ msg: "Reward not found" }); + } + + const user = await User.findById(req.user.id).session(session); + if (!user) { + await session.abortTransaction(); + session.endSession(); + return res.status(404).json({ msg: "User not found" }); + } + + if (user.points < reward.cost) { + await session.abortTransaction(); + session.endSession(); + return res.status(400).json({ msg: "Not enough points" }); + } + + if (reward.isPremium && !user.isPremium) { + await session.abortTransaction(); + session.endSession(); + return res.status(403).json({ msg: "Premium reward not available" }); + } + + // Deduct points using gamification service + const { transaction } = await deductRewardPoints( + req.user.id, + reward._id, + reward.cost, + session + ); + + await session.commitTransaction(); + session.endSession(); + + res.json({ + msg: "Reward redeemed successfully", + pointsDeducted: Math.abs(transaction.amount), + newBalance: transaction.balanceAfter, + }); + } catch (err) { + await session.abortTransaction(); + session.endSession(); + throw err; } - - const user = await User.findById(req.user.id); - if (!user) { - return res.status(404).json({ msg: "User not found" }); - } - - if (user.points < reward.cost) { - return res.status(400).json({ msg: "Not enough points" }); - } - - if (reward.isPremium && !user.isPremium) { - return res.status(403).json({ msg: "Premium reward not available" }); - } - - user.points -= reward.cost; - await user.save(); - - res.json({ msg: "Reward redeemed successfully" }); - } catch (err) { - console.error(err.message); - res.status(500).send("Server error"); - } -}); + }), +); module.exports = router; diff --git a/backend/routes/streets.js b/backend/routes/streets.js index 3e90111..0eda9a6 100644 --- a/backend/routes/streets.js +++ b/backend/routes/streets.js @@ -1,39 +1,64 @@ const express = require("express"); +const mongoose = require("mongoose"); const Street = require("../models/Street"); +const User = require("../models/User"); const auth = require("../middleware/auth"); +const { asyncHandler } = require("../middleware/errorHandler"); +const { + createStreetValidation, + streetIdValidation, +} = require("../middleware/validators/streetValidator"); +const { + awardStreetAdoptionPoints, + checkAndAwardBadges, +} = require("../services/gamificationService"); const router = express.Router(); -// Get all streets -router.get("/", async (req, res) => { - try { - const streets = await Street.find(); - res.json(streets); - } catch (err) { - console.error(err.message); - res.status(500).send("Server error"); - } -}); +// Get all streets (with pagination) +router.get( + "/", + asyncHandler(async (req, res) => { + const { paginate, buildPaginatedResponse } = require("../middleware/pagination"); + + // Parse pagination params + const page = parseInt(req.query.page) || 1; + const limit = Math.min(parseInt(req.query.limit) || 10, 100); + const skip = (page - 1) * limit; + + const streets = await Street.find() + .sort({ name: 1 }) + .skip(skip) + .limit(limit) + .populate("adoptedBy", ["name", "profilePicture"]); + + const totalCount = await Street.countDocuments(); + + res.json(buildPaginatedResponse(streets, totalCount, page, limit)); + }), +); // Get single street -router.get("/:id", async (req, res) => { - try { +router.get( + "/:id", + streetIdValidation, + asyncHandler(async (req, res) => { const street = await Street.findById(req.params.id); if (!street) { return res.status(404).json({ msg: "Street not found" }); } res.json(street); - } catch (err) { - console.error(err.message); - res.status(500).send("Server error"); - } -}); + }), +); // Create a street -router.post("/", auth, async (req, res) => { - const { name, location } = req.body; +router.post( + "/", + auth, + createStreetValidation, + asyncHandler(async (req, res) => { + const { name, location } = req.body; - try { const newStreet = new Street({ name, location, @@ -41,34 +66,76 @@ router.post("/", auth, async (req, res) => { const street = await newStreet.save(); res.json(street); - } catch (err) { - console.error(err.message); - res.status(500).send("Server error"); - } -}); + }), +); // Adopt a street -router.put("/adopt/:id", auth, async (req, res) => { - try { - const street = await Street.findById(req.params.id); - if (!street) { - return res.status(404).json({ msg: "Street not found" }); +router.put( + "/adopt/:id", + auth, + streetIdValidation, + asyncHandler(async (req, res) => { + const session = await mongoose.startSession(); + session.startTransaction(); + + try { + const street = await Street.findById(req.params.id).session(session); + if (!street) { + await session.abortTransaction(); + session.endSession(); + return res.status(404).json({ msg: "Street not found" }); + } + + if (street.status === "adopted") { + await session.abortTransaction(); + session.endSession(); + return res.status(400).json({ msg: "Street already adopted" }); + } + + // Check if user has already adopted this street + const user = await User.findById(req.user.id).session(session); + if (user.adoptedStreets.includes(req.params.id)) { + await session.abortTransaction(); + session.endSession(); + return res + .status(400) + .json({ msg: "You have already adopted this street" }); + } + + // Update street + street.adoptedBy = req.user.id; + street.status = "adopted"; + await street.save({ session }); + + // Update user's adoptedStreets array + user.adoptedStreets.push(street._id); + await user.save({ session }); + + // Award points for street adoption + const { transaction } = await awardStreetAdoptionPoints( + req.user.id, + street._id, + session, + ); + + // Check and award badges + const newBadges = await checkAndAwardBadges(req.user.id, session); + + await session.commitTransaction(); + session.endSession(); + + res.json({ + street, + pointsAwarded: transaction.amount, + newBalance: transaction.balanceAfter, + badgesEarned: newBadges, + }); + } catch (err) { + await session.abortTransaction(); + session.endSession(); + throw err; } - - if (street.status === "adopted") { - return res.status(400).json({ msg: "Street already adopted" }); - } - - street.adoptedBy = req.user.id; - street.status = "adopted"; - - await street.save(); - - res.json(street); - } catch (err) { - console.error(err.message); - res.status(500).send("Server error"); - } -}); + }), +); module.exports = router; diff --git a/backend/routes/tasks.js b/backend/routes/tasks.js index 6fd5a39..79ef51d 100644 --- a/backend/routes/tasks.js +++ b/backend/routes/tasks.js @@ -1,25 +1,53 @@ const express = require("express"); +const mongoose = require("mongoose"); const Task = require("../models/Task"); +const User = require("../models/User"); const auth = require("../middleware/auth"); +const { asyncHandler } = require("../middleware/errorHandler"); +const { + createTaskValidation, + taskIdValidation, +} = require("../middleware/validators/taskValidator"); +const { + awardTaskCompletionPoints, + checkAndAwardBadges, +} = require("../services/gamificationService"); const router = express.Router(); -// Get all tasks for user -router.get("/", auth, async (req, res) => { - try { - const tasks = await Task.find({ completedBy: req.user.id }); - res.json(tasks); - } catch (err) { - console.error(err.message); - res.status(500).send("Server error"); - } -}); +// Get all tasks for user (with pagination) +router.get( + "/", + auth, + asyncHandler(async (req, res) => { + const { paginate, buildPaginatedResponse } = require("../middleware/pagination"); + + // Parse pagination params + const page = parseInt(req.query.page) || 1; + const limit = Math.min(parseInt(req.query.limit) || 10, 100); + const skip = (page - 1) * limit; + + const tasks = await Task.find({ completedBy: req.user.id }) + .sort({ createdAt: -1 }) + .skip(skip) + .limit(limit) + .populate("street", ["name"]) + .populate("completedBy", ["name"]); + + const totalCount = await Task.countDocuments({ completedBy: req.user.id }); + + res.json(buildPaginatedResponse(tasks, totalCount, page, limit)); + }), +); // Create a task -router.post("/", auth, async (req, res) => { - const { street, description } = req.body; +router.post( + "/", + auth, + createTaskValidation, + asyncHandler(async (req, res) => { + const { street, description } = req.body; - try { const newTask = new Task({ street, description, @@ -27,30 +55,70 @@ router.post("/", auth, async (req, res) => { const task = await newTask.save(); res.json(task); - } catch (err) { - console.error(err.message); - res.status(500).send("Server error"); - } -}); + }), +); // Complete a task -router.put("/:id", auth, async (req, res) => { - try { - const task = await Task.findById(req.params.id); - if (!task) { - return res.status(404).json({ msg: "Task not found" }); +router.put( + "/:id", + auth, + taskIdValidation, + asyncHandler(async (req, res) => { + const session = await mongoose.startSession(); + session.startTransaction(); + + try { + const task = await Task.findById(req.params.id).session(session); + if (!task) { + await session.abortTransaction(); + session.endSession(); + return res.status(404).json({ msg: "Task not found" }); + } + + // Check if task is already completed + if (task.status === "completed") { + await session.abortTransaction(); + session.endSession(); + return res.status(400).json({ msg: "Task already completed" }); + } + + // Update task + task.completedBy = req.user.id; + task.status = "completed"; + await task.save({ session }); + + // Update user's completedTasks array + const user = await User.findById(req.user.id).session(session); + if (!user.completedTasks.includes(task._id)) { + user.completedTasks.push(task._id); + await user.save({ session }); + } + + // Award points for task completion + const { transaction } = await awardTaskCompletionPoints( + req.user.id, + task._id, + session, + ); + + // Check and award badges + const newBadges = await checkAndAwardBadges(req.user.id, session); + + await session.commitTransaction(); + session.endSession(); + + res.json({ + task, + pointsAwarded: transaction.amount, + newBalance: transaction.balanceAfter, + badgesEarned: newBadges, + }); + } catch (err) { + await session.abortTransaction(); + session.endSession(); + throw err; } - - task.completedBy = req.user.id; - task.status = "completed"; - - await task.save(); - - res.json(task); - } catch (err) { - console.error(err.message); - res.status(500).send("Server error"); - } -}); + }), +); module.exports = router; diff --git a/backend/services/gamificationService.js b/backend/services/gamificationService.js new file mode 100644 index 0000000..47ca39d --- /dev/null +++ b/backend/services/gamificationService.js @@ -0,0 +1,390 @@ +const mongoose = require("mongoose"); +const User = require("../models/User"); +const PointTransaction = require("../models/PointTransaction"); +const Badge = require("../models/Badge"); +const UserBadge = require("../models/UserBadge"); + +/** + * Point rewards for different actions + */ +const POINT_VALUES = { + STREET_ADOPTION: 100, + TASK_COMPLETION: 50, + POST_CREATION: 10, + EVENT_PARTICIPATION: 75, +}; + +/** + * Awards points to a user with transaction tracking + * Uses MongoDB transactions to ensure atomicity + * + * @param {string} userId - User ID + * @param {number} amount - Points to award (can be negative for deductions) + * @param {string} type - Transaction type (street_adoption, task_completion, etc.) + * @param {string} description - Human-readable description + * @param {Object} relatedEntity - Related entity {entityType, entityId} + * @param {Object} session - Optional MongoDB session for transaction + * @returns {Promise} - Updated user and transaction + */ +async function awardPoints( + userId, + amount, + type, + description, + relatedEntity = {}, + session = null +) { + const shouldEndSession = !session; + const localSession = session || (await mongoose.startSession()); + + try { + if (shouldEndSession) { + localSession.startTransaction(); + } + + // Get current user points + const user = await User.findById(userId).session(localSession); + if (!user) { + throw new Error("User not found"); + } + + // Calculate new balance + const newBalance = Math.max(0, user.points + amount); + + // Create point transaction record + const transaction = new PointTransaction({ + user: userId, + amount, + type, + description, + relatedEntity, + balanceAfter: newBalance, + }); + + await transaction.save({ session: localSession }); + + // Update user points + user.points = newBalance; + await user.save({ session: localSession }); + + if (shouldEndSession) { + await localSession.commitTransaction(); + } + + return { user, transaction }; + } catch (error) { + if (shouldEndSession) { + await localSession.abortTransaction(); + } + throw error; + } finally { + if (shouldEndSession) { + localSession.endSession(); + } + } +} + +/** + * Award points for street adoption + */ +async function awardStreetAdoptionPoints(userId, streetId, session = null) { + return awardPoints( + userId, + POINT_VALUES.STREET_ADOPTION, + "street_adoption", + "Adopted a street", + { entityType: "Street", entityId: streetId }, + session + ); +} + +/** + * Award points for task completion + */ +async function awardTaskCompletionPoints(userId, taskId, session = null) { + return awardPoints( + userId, + POINT_VALUES.TASK_COMPLETION, + "task_completion", + "Completed a task", + { entityType: "Task", entityId: taskId }, + session + ); +} + +/** + * Award points for post creation + */ +async function awardPostCreationPoints(userId, postId, session = null) { + return awardPoints( + userId, + POINT_VALUES.POST_CREATION, + "post_creation", + "Created a post", + { entityType: "Post", entityId: postId }, + session + ); +} + +/** + * Award points for event participation + */ +async function awardEventParticipationPoints(userId, eventId, session = null) { + return awardPoints( + userId, + POINT_VALUES.EVENT_PARTICIPATION, + "event_participation", + "Participated in an event", + { entityType: "Event", entityId: eventId }, + session + ); +} + +/** + * Deduct points for reward redemption + */ +async function deductRewardPoints(userId, rewardId, amount, session = null) { + return awardPoints( + userId, + -amount, + "reward_redemption", + "Redeemed a reward", + { entityType: "Reward", entityId: rewardId }, + session + ); +} + +/** + * Check if a user has already earned a specific badge + */ +async function hasEarnedBadge(userId, badgeId) { + const userBadge = await UserBadge.findOne({ user: userId, badge: badgeId }); + return !!userBadge; +} + +/** + * Award a badge to a user + * Prevents duplicate badge awards + */ +async function awardBadge(userId, badgeId, session = null) { + const shouldEndSession = !session; + const localSession = session || (await mongoose.startSession()); + + try { + if (shouldEndSession) { + localSession.startTransaction(); + } + + // Check if badge already earned + const existingBadge = await UserBadge.findOne({ + user: userId, + badge: badgeId, + }).session(localSession); + + if (existingBadge) { + if (shouldEndSession) { + await localSession.commitTransaction(); + } + return { awarded: false, userBadge: existingBadge, isNew: false }; + } + + // Award the badge + const userBadge = new UserBadge({ + user: userId, + badge: badgeId, + }); + + await userBadge.save({ session: localSession }); + + if (shouldEndSession) { + await localSession.commitTransaction(); + } + + return { awarded: true, userBadge, isNew: true }; + } catch (error) { + if (shouldEndSession) { + await localSession.abortTransaction(); + } + throw error; + } finally { + if (shouldEndSession) { + localSession.endSession(); + } + } +} + +/** + * Get user statistics for badge criteria checking + */ +async function getUserStats(userId) { + const user = await User.findById(userId) + .populate("adoptedStreets") + .populate("completedTasks") + .populate("posts") + .populate("events"); + + if (!user) { + throw new Error("User not found"); + } + + return { + streetAdoptions: user.adoptedStreets.length, + taskCompletions: user.completedTasks.length, + postCreations: user.posts.length, + eventParticipations: user.events.length, + pointsEarned: user.points, + }; +} + +/** + * Check and award eligible badges for a user + * This should be called after any action that might trigger badge eligibility + * + * @param {string} userId - User ID + * @param {Object} session - Optional MongoDB session for transaction + * @returns {Promise} - Array of newly awarded badges + */ +async function checkAndAwardBadges(userId, session = null) { + try { + // Get user stats + const stats = await getUserStats(userId); + + // Get all badges + const badges = await Badge.find().sort({ order: 1 }); + + const newlyAwardedBadges = []; + + // Check each badge's criteria + for (const badge of badges) { + const alreadyEarned = await hasEarnedBadge(userId, badge._id); + + if (!alreadyEarned && isBadgeEligible(stats, badge)) { + const result = await awardBadge(userId, badge._id, session); + if (result.awarded) { + newlyAwardedBadges.push(badge); + } + } + } + + return newlyAwardedBadges; + } catch (error) { + console.error("Error checking badges:", error); + return []; + } +} + +/** + * Check if user meets badge criteria + */ +function isBadgeEligible(stats, badge) { + const { type, threshold } = badge.criteria; + + switch (type) { + case "street_adoptions": + return stats.streetAdoptions >= threshold; + case "task_completions": + return stats.taskCompletions >= threshold; + case "post_creations": + return stats.postCreations >= threshold; + case "event_participations": + return stats.eventParticipations >= threshold; + case "points_earned": + return stats.pointsEarned >= threshold; + case "special": + // Special badges require manual awarding + return false; + default: + return false; + } +} + +/** + * Get user's badge progress + */ +async function getUserBadgeProgress(userId) { + try { + const stats = await getUserStats(userId); + const badges = await Badge.find().sort({ order: 1 }); + const earnedBadges = await UserBadge.find({ user: userId }).populate( + "badge" + ); + const earnedBadgeIds = new Set( + earnedBadges.map((ub) => ub.badge._id.toString()) + ); + + return badges.map((badge) => { + const earned = earnedBadgeIds.has(badge._id.toString()); + const eligible = isBadgeEligible(stats, badge); + + let progress = 0; + const { type, threshold } = badge.criteria; + + switch (type) { + case "street_adoptions": + progress = Math.min(100, (stats.streetAdoptions / threshold) * 100); + break; + case "task_completions": + progress = Math.min(100, (stats.taskCompletions / threshold) * 100); + break; + case "post_creations": + progress = Math.min(100, (stats.postCreations / threshold) * 100); + break; + case "event_participations": + progress = Math.min( + 100, + (stats.eventParticipations / threshold) * 100 + ); + break; + case "points_earned": + progress = Math.min(100, (stats.pointsEarned / threshold) * 100); + break; + default: + progress = 0; + } + + return { + badge, + earned, + eligible, + progress: Math.round(progress), + currentValue: getCurrentValue(stats, type), + targetValue: threshold, + }; + }); + } catch (error) { + console.error("Error getting badge progress:", error); + return []; + } +} + +function getCurrentValue(stats, type) { + switch (type) { + case "street_adoptions": + return stats.streetAdoptions; + case "task_completions": + return stats.taskCompletions; + case "post_creations": + return stats.postCreations; + case "event_participations": + return stats.eventParticipations; + case "points_earned": + return stats.pointsEarned; + default: + return 0; + } +} + +module.exports = { + POINT_VALUES, + awardPoints, + awardStreetAdoptionPoints, + awardTaskCompletionPoints, + awardPostCreationPoints, + awardEventParticipationPoints, + deductRewardPoints, + awardBadge, + hasEarnedBadge, + checkAndAwardBadges, + getUserStats, + getUserBadgeProgress, +};