feat(backend): implement complete gamification system

Implement comprehensive points and badges system with MongoDB transactions:

Point System:
- Create PointTransaction model for transaction history
- Award points atomically using MongoDB transactions
- Point values: street adoption (+100), task completion (+50), post creation (+10), event participation (+75)
- Track balance after each transaction
- Support point deduction for reward redemption

Badge System:
- Create Badge and UserBadge models
- Define badge criteria types: street_adoptions, task_completions, post_creations, event_participations, points_earned
- Auto-award badges based on user achievements
- Badge rarity levels: common, rare, epic, legendary
- Track badge progress for users
- Prevent duplicate badge awards

Gamification Service:
- Implement gamificationService.js with 390 lines of logic
- awardPoints() with transaction support
- checkAndAwardBadges() for auto-awarding
- getUserBadgeProgress() for progress tracking
- getUserStats() for achievement statistics
- Atomic operations prevent double-awarding

Integration:
- Streets route: Award points and badges on adoption
- Tasks route: Award points and badges on completion
- Posts route: Award points and badges on creation
- Events route: Award points and badges on RSVP
- Rewards route: Deduct points on redemption
- Badges API: List badges, track progress, view earned badges

Updated User Model:
- Add points field (default 0)
- Add earnedBadges virtual relationship
- Add indexes for performance (points for leaderboards)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
William Valentin
2025-11-01 10:42:51 -07:00
parent b3dc608750
commit e7396c10d6
11 changed files with 1198 additions and 203 deletions

View File

@@ -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;