feat: migrate Event and Reward models from MongoDB to CouchDB
- Replace Event model with CouchDB version using couchdbService - Replace Reward model with CouchDB version using couchdbService - Update event and reward routes to use new model interfaces - Handle participant management with embedded user data - Maintain status transitions for events (upcoming, ongoing, completed, cancelled) - Preserve catalog functionality and premium vs regular rewards - Update validators to accept CouchDB document IDs - Add rewards design document to couchdbService - Update test helpers for new model structure - Initialize CouchDB alongside MongoDB in server.js for backward compatibility - Fix linting issues in migrated routes 🤖 Generated with [AI Assistant] Co-Authored-By: AI Assistant <noreply@ai-assistant.com>
This commit is contained in:
@@ -1,43 +1,214 @@
|
||||
const mongoose = require("mongoose");
|
||||
const couchdbService = require("../services/couchdbService");
|
||||
|
||||
const EventSchema = new mongoose.Schema(
|
||||
{
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
date: {
|
||||
type: Date,
|
||||
required: true,
|
||||
index: true,
|
||||
},
|
||||
location: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
participants: [
|
||||
{
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: "User",
|
||||
class Event {
|
||||
static async create(eventData) {
|
||||
const event = {
|
||||
_id: `event_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||||
type: "event",
|
||||
title: eventData.title,
|
||||
description: eventData.description,
|
||||
date: eventData.date,
|
||||
location: eventData.location,
|
||||
participants: [],
|
||||
participantsCount: 0,
|
||||
status: eventData.status || "upcoming",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
return await couchdbService.create(event);
|
||||
}
|
||||
|
||||
static async findById(eventId) {
|
||||
return await couchdbService.getById(eventId);
|
||||
}
|
||||
|
||||
static async find(query = {}, options = {}) {
|
||||
const defaultQuery = {
|
||||
type: "event",
|
||||
...query
|
||||
};
|
||||
|
||||
return await couchdbService.find({
|
||||
selector: defaultQuery,
|
||||
...options
|
||||
});
|
||||
}
|
||||
|
||||
static async findOne(query) {
|
||||
const events = await this.find(query, { limit: 1 });
|
||||
return events[0] || null;
|
||||
}
|
||||
|
||||
static async update(eventId, updateData) {
|
||||
const event = await this.findById(eventId);
|
||||
if (!event) {
|
||||
throw new Error("Event not found");
|
||||
}
|
||||
|
||||
const updatedEvent = {
|
||||
...event,
|
||||
...updateData,
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
return await couchdbService.update(eventId, updatedEvent);
|
||||
}
|
||||
|
||||
static async delete(eventId) {
|
||||
return await couchdbService.delete(eventId);
|
||||
}
|
||||
|
||||
static async addParticipant(eventId, userId, userName, userProfilePicture) {
|
||||
const event = await this.findById(eventId);
|
||||
if (!event) {
|
||||
throw new Error("Event not found");
|
||||
}
|
||||
|
||||
// Check if user is already a participant
|
||||
const existingParticipant = event.participants.find(p => p.userId === userId);
|
||||
if (existingParticipant) {
|
||||
throw new Error("User already participating in this event");
|
||||
}
|
||||
|
||||
// Add participant with embedded user data
|
||||
const newParticipant = {
|
||||
userId: userId,
|
||||
name: userName,
|
||||
profilePicture: userProfilePicture || "",
|
||||
joinedAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
event.participants.push(newParticipant);
|
||||
event.participantsCount = event.participants.length;
|
||||
event.updatedAt = new Date().toISOString();
|
||||
|
||||
return await couchdbService.update(eventId, event);
|
||||
}
|
||||
|
||||
static async removeParticipant(eventId, userId) {
|
||||
const event = await this.findById(eventId);
|
||||
if (!event) {
|
||||
throw new Error("Event not found");
|
||||
}
|
||||
|
||||
// Remove participant
|
||||
event.participants = event.participants.filter(p => p.userId !== userId);
|
||||
event.participantsCount = event.participants.length;
|
||||
event.updatedAt = new Date().toISOString();
|
||||
|
||||
return await couchdbService.update(eventId, event);
|
||||
}
|
||||
|
||||
static async updateStatus(eventId, newStatus) {
|
||||
const validStatuses = ["upcoming", "ongoing", "completed", "cancelled"];
|
||||
if (!validStatuses.includes(newStatus)) {
|
||||
throw new Error("Invalid status");
|
||||
}
|
||||
|
||||
return await this.update(eventId, { status: newStatus });
|
||||
}
|
||||
|
||||
static async findByStatus(status) {
|
||||
return await this.find({ status });
|
||||
}
|
||||
|
||||
static async findByDateRange(startDate, endDate) {
|
||||
return await couchdbService.find({
|
||||
selector: {
|
||||
type: "event",
|
||||
date: {
|
||||
$gte: startDate.toISOString(),
|
||||
$lte: endDate.toISOString()
|
||||
}
|
||||
},
|
||||
],
|
||||
status: {
|
||||
type: String,
|
||||
enum: ["upcoming", "ongoing", "completed", "cancelled"],
|
||||
default: "upcoming",
|
||||
index: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
timestamps: true,
|
||||
},
|
||||
);
|
||||
sort: [{ date: "asc" }]
|
||||
});
|
||||
}
|
||||
|
||||
// Index for querying upcoming events
|
||||
EventSchema.index({ date: 1, status: 1 });
|
||||
static async findByParticipant(userId) {
|
||||
return await couchdbService.view("events", "by-participant", {
|
||||
key: userId,
|
||||
include_docs: true
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = mongoose.model("Event", EventSchema);
|
||||
static async getUpcomingEvents(limit = 10) {
|
||||
const now = new Date().toISOString();
|
||||
return await couchdbService.find({
|
||||
selector: {
|
||||
type: "event",
|
||||
status: "upcoming",
|
||||
date: { $gte: now }
|
||||
},
|
||||
sort: [{ date: "asc" }],
|
||||
limit
|
||||
});
|
||||
}
|
||||
|
||||
static async getAllPaginated(page = 1, limit = 10) {
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const events = await couchdbService.find({
|
||||
selector: { type: "event" },
|
||||
sort: [{ date: "desc" }],
|
||||
skip,
|
||||
limit
|
||||
});
|
||||
|
||||
// Get total count
|
||||
const totalCount = await couchdbService.find({
|
||||
selector: { type: "event" },
|
||||
fields: ["_id"]
|
||||
});
|
||||
|
||||
return {
|
||||
events,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
totalCount: totalCount.length,
|
||||
totalPages: Math.ceil(totalCount.length / limit)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
static async getEventsByUser(userId) {
|
||||
return await this.find({
|
||||
"participants": { $elemMatch: { userId: userId } }
|
||||
});
|
||||
}
|
||||
|
||||
// Migration helper
|
||||
static async migrateFromMongo(mongoEvent) {
|
||||
const eventData = {
|
||||
title: mongoEvent.title,
|
||||
description: mongoEvent.description,
|
||||
date: mongoEvent.date,
|
||||
location: mongoEvent.location,
|
||||
status: mongoEvent.status || "upcoming"
|
||||
};
|
||||
|
||||
// Create event without participants first
|
||||
const event = await this.create(eventData);
|
||||
|
||||
// If there are participants, add them with embedded user data
|
||||
if (mongoEvent.participants && mongoEvent.participants.length > 0) {
|
||||
for (const participantId of mongoEvent.participants) {
|
||||
try {
|
||||
// Get user data to embed
|
||||
const user = await couchdbService.findUserById(participantId.toString());
|
||||
if (user) {
|
||||
await this.addParticipant(event._id, participantId.toString(), user.name, user.profilePicture);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error migrating participant ${participantId}:`, error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return event;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Event;
|
||||
@@ -1,27 +1,273 @@
|
||||
const mongoose = require("mongoose");
|
||||
const couchdbService = require("../services/couchdbService");
|
||||
|
||||
const RewardSchema = new mongoose.Schema(
|
||||
{
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
cost: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
isPremium: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
timestamps: true,
|
||||
},
|
||||
);
|
||||
class Reward {
|
||||
static async create(rewardData) {
|
||||
const reward = {
|
||||
_id: `reward_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||||
type: "reward",
|
||||
name: rewardData.name,
|
||||
description: rewardData.description,
|
||||
cost: rewardData.cost,
|
||||
isPremium: rewardData.isPremium || false,
|
||||
isActive: rewardData.isActive !== undefined ? rewardData.isActive : true,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
module.exports = mongoose.model("Reward", RewardSchema);
|
||||
return await couchdbService.create(reward);
|
||||
}
|
||||
|
||||
static async findById(rewardId) {
|
||||
return await couchdbService.getById(rewardId);
|
||||
}
|
||||
|
||||
static async find(query = {}, options = {}) {
|
||||
const defaultQuery = {
|
||||
type: "reward",
|
||||
...query
|
||||
};
|
||||
|
||||
return await couchdbService.find({
|
||||
selector: defaultQuery,
|
||||
...options
|
||||
});
|
||||
}
|
||||
|
||||
static async findOne(query) {
|
||||
const rewards = await this.find(query, { limit: 1 });
|
||||
return rewards[0] || null;
|
||||
}
|
||||
|
||||
static async update(rewardId, updateData) {
|
||||
const reward = await this.findById(rewardId);
|
||||
if (!reward) {
|
||||
throw new Error("Reward not found");
|
||||
}
|
||||
|
||||
const updatedReward = {
|
||||
...reward,
|
||||
...updateData,
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
return await couchdbService.update(rewardId, updatedReward);
|
||||
}
|
||||
|
||||
static async delete(rewardId) {
|
||||
return await couchdbService.delete(rewardId);
|
||||
}
|
||||
|
||||
static async findByCostRange(minCost, maxCost) {
|
||||
return await couchdbService.find({
|
||||
selector: {
|
||||
type: "reward",
|
||||
cost: { $gte: minCost, $lte: maxCost }
|
||||
},
|
||||
sort: [{ cost: "asc" }]
|
||||
});
|
||||
}
|
||||
|
||||
static async findByPremiumStatus(isPremium) {
|
||||
return await this.find({ isPremium });
|
||||
}
|
||||
|
||||
static async getActiveRewards() {
|
||||
return await this.find({ isActive: true });
|
||||
}
|
||||
|
||||
static async getPremiumRewards() {
|
||||
return await this.find({ isPremium: true, isActive: true });
|
||||
}
|
||||
|
||||
static async getRegularRewards() {
|
||||
return await this.find({ isPremium: false, isActive: true });
|
||||
}
|
||||
|
||||
static async getAllPaginated(page = 1, limit = 10) {
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const rewards = await couchdbService.find({
|
||||
selector: { type: "reward" },
|
||||
sort: [{ cost: "asc" }],
|
||||
skip,
|
||||
limit
|
||||
});
|
||||
|
||||
// Get total count
|
||||
const totalCount = await couchdbService.find({
|
||||
selector: { type: "reward" },
|
||||
fields: ["_id"]
|
||||
});
|
||||
|
||||
return {
|
||||
rewards,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
totalCount: totalCount.length,
|
||||
totalPages: Math.ceil(totalCount.length / limit)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
static async redeemReward(userId, rewardId) {
|
||||
const reward = await this.findById(rewardId);
|
||||
if (!reward) {
|
||||
throw new Error("Reward not found");
|
||||
}
|
||||
|
||||
if (!reward.isActive) {
|
||||
throw new Error("Reward is not available");
|
||||
}
|
||||
|
||||
const user = await couchdbService.findUserById(userId);
|
||||
if (!user) {
|
||||
throw new Error("User not found");
|
||||
}
|
||||
|
||||
if (user.points < reward.cost) {
|
||||
throw new Error("Not enough points");
|
||||
}
|
||||
|
||||
if (reward.isPremium && !user.isPremium) {
|
||||
throw new Error("Premium reward not available");
|
||||
}
|
||||
|
||||
// Deduct points using couchdbService method
|
||||
const updatedUser = await couchdbService.updateUserPoints(
|
||||
userId,
|
||||
-reward.cost,
|
||||
`Redeemed reward: ${reward.name}`,
|
||||
{
|
||||
entityType: 'Reward',
|
||||
entityId: rewardId,
|
||||
entityName: reward.name
|
||||
}
|
||||
);
|
||||
|
||||
// Create redemption record
|
||||
const redemption = {
|
||||
_id: `redemption_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||||
type: "reward_redemption",
|
||||
user: {
|
||||
userId: userId,
|
||||
name: user.name
|
||||
},
|
||||
reward: {
|
||||
rewardId: rewardId,
|
||||
name: reward.name,
|
||||
description: reward.description,
|
||||
cost: reward.cost
|
||||
},
|
||||
pointsDeducted: reward.cost,
|
||||
balanceAfter: updatedUser.points,
|
||||
redeemedAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
await couchdbService.create(redemption);
|
||||
|
||||
return {
|
||||
redemption,
|
||||
pointsDeducted: reward.cost,
|
||||
newBalance: updatedUser.points
|
||||
};
|
||||
}
|
||||
|
||||
static async getUserRedemptions(userId, limit = 20) {
|
||||
return await couchdbService.find({
|
||||
selector: {
|
||||
type: "reward_redemption",
|
||||
"user.userId": userId
|
||||
},
|
||||
sort: [{ redeemedAt: "desc" }],
|
||||
limit
|
||||
});
|
||||
}
|
||||
|
||||
static async getRewardStats(rewardId) {
|
||||
const redemptions = await couchdbService.find({
|
||||
selector: {
|
||||
type: "reward_redemption",
|
||||
"reward.rewardId": rewardId
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
totalRedemptions: redemptions.length,
|
||||
totalPointsSpent: redemptions.reduce((sum, r) => sum + r.pointsDeducted, 0),
|
||||
lastRedeemed: redemptions.length > 0 ? redemptions[0].redeemedAt : null
|
||||
};
|
||||
}
|
||||
|
||||
static async getCatalogStats() {
|
||||
const rewards = await this.getActiveRewards();
|
||||
const premium = await this.getPremiumRewards();
|
||||
const regular = await this.getRegularRewards();
|
||||
|
||||
return {
|
||||
totalRewards: rewards.length,
|
||||
premiumRewards: premium.length,
|
||||
regularRewards: regular.length,
|
||||
averageCost: rewards.reduce((sum, r) => sum + r.cost, 0) / rewards.length || 0,
|
||||
minCost: Math.min(...rewards.map(r => r.cost)),
|
||||
maxCost: Math.max(...rewards.map(r => r.cost))
|
||||
};
|
||||
}
|
||||
|
||||
static async searchRewards(searchTerm, options = {}) {
|
||||
const query = {
|
||||
selector: {
|
||||
type: "reward",
|
||||
isActive: true,
|
||||
$or: [
|
||||
{ name: { $regex: searchTerm, $options: "i" } },
|
||||
{ description: { $regex: searchTerm, $options: "i" } }
|
||||
]
|
||||
},
|
||||
...options
|
||||
};
|
||||
|
||||
return await couchdbService.find(query);
|
||||
}
|
||||
|
||||
// Migration helper
|
||||
static async migrateFromMongo(mongoReward) {
|
||||
const rewardData = {
|
||||
name: mongoReward.name,
|
||||
description: mongoReward.description,
|
||||
cost: mongoReward.cost,
|
||||
isPremium: mongoReward.isPremium || false,
|
||||
isActive: true
|
||||
};
|
||||
|
||||
return await this.create(rewardData);
|
||||
}
|
||||
|
||||
// Bulk operations for admin
|
||||
static async bulkCreate(rewardsData) {
|
||||
const rewards = rewardsData.map(data => ({
|
||||
_id: `reward_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||||
type: "reward",
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
cost: data.cost,
|
||||
isPremium: data.isPremium || false,
|
||||
isActive: data.isActive !== undefined ? data.isActive : true,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString()
|
||||
}));
|
||||
|
||||
return await couchdbService.bulkDocs(rewards);
|
||||
}
|
||||
|
||||
static async toggleActiveStatus(rewardId) {
|
||||
const reward = await this.findById(rewardId);
|
||||
if (!reward) {
|
||||
throw new Error("Reward not found");
|
||||
}
|
||||
|
||||
return await this.update(rewardId, { isActive: !reward.isActive });
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Reward;
|
||||
Reference in New Issue
Block a user