feat: Migrate Street and Task models from MongoDB to CouchDB
- Replace Street model with CouchDB-based implementation - Replace Task model with CouchDB-based implementation - Update routes to use new model interfaces - Handle geospatial queries with CouchDB design documents - Maintain adoption functionality and middleware - Use denormalized document structure with embedded data - Update test files to work with new models - Ensure API compatibility while using CouchDB underneath 🤖 Generated with [AI Assistant] Co-Authored-By: AI Assistant <noreply@ai-assistant.com>
This commit is contained in:
@@ -1,69 +1,282 @@
|
||||
const mongoose = require("mongoose");
|
||||
const couchdbService = require("../services/couchdbService");
|
||||
|
||||
const StreetSchema = new mongoose.Schema(
|
||||
{
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
location: {
|
||||
type: {
|
||||
type: String,
|
||||
enum: ["Point"],
|
||||
required: true,
|
||||
},
|
||||
coordinates: {
|
||||
type: [Number],
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
adoptedBy: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: "User",
|
||||
},
|
||||
status: {
|
||||
type: String,
|
||||
enum: ["available", "adopted"],
|
||||
default: "available",
|
||||
},
|
||||
},
|
||||
{
|
||||
timestamps: true,
|
||||
},
|
||||
);
|
||||
|
||||
StreetSchema.index({ location: "2dsphere" });
|
||||
StreetSchema.index({ adoptedBy: 1 });
|
||||
StreetSchema.index({ status: 1 });
|
||||
|
||||
// Cascade cleanup when a street is deleted
|
||||
StreetSchema.pre("deleteOne", { document: true, query: false }, async function () {
|
||||
const User = mongoose.model("User");
|
||||
const Task = mongoose.model("Task");
|
||||
|
||||
// Remove street from user's adoptedStreets
|
||||
if (this.adoptedBy) {
|
||||
await User.updateOne(
|
||||
{ _id: this.adoptedBy },
|
||||
{ $pull: { adoptedStreets: this._id } }
|
||||
);
|
||||
class Street {
|
||||
constructor(data) {
|
||||
this._id = data._id || null;
|
||||
this._rev = data._rev || null;
|
||||
this.type = "street";
|
||||
this.name = data.name;
|
||||
this.location = data.location;
|
||||
this.adoptedBy = data.adoptedBy || null;
|
||||
this.status = data.status || "available";
|
||||
this.createdAt = data.createdAt || new Date().toISOString();
|
||||
this.updatedAt = data.updatedAt || new Date().toISOString();
|
||||
this.stats = data.stats || {
|
||||
completedTasksCount: 0,
|
||||
reportsCount: 0,
|
||||
openReportsCount: 0
|
||||
};
|
||||
}
|
||||
|
||||
// Delete all tasks associated with this street
|
||||
await Task.deleteMany({ street: this._id });
|
||||
});
|
||||
// Static methods for MongoDB-like interface
|
||||
static async find(filter = {}) {
|
||||
try {
|
||||
await couchdbService.initialize();
|
||||
|
||||
// Convert MongoDB filter to CouchDB selector
|
||||
const selector = { type: "street", ...filter };
|
||||
|
||||
// Handle special cases
|
||||
if (filter._id) {
|
||||
selector._id = filter._id;
|
||||
}
|
||||
|
||||
if (filter.status) {
|
||||
selector.status = filter.status;
|
||||
}
|
||||
|
||||
if (filter.adoptedBy) {
|
||||
selector["adoptedBy.userId"] = filter.adoptedBy;
|
||||
}
|
||||
|
||||
// Update user relationship when street is adopted
|
||||
StreetSchema.post("save", async function (doc) {
|
||||
if (doc.adoptedBy && doc.status === "adopted") {
|
||||
const User = mongoose.model("User");
|
||||
const query = {
|
||||
selector,
|
||||
sort: filter.sort || [{ name: "asc" }]
|
||||
};
|
||||
|
||||
// Add street to user's adoptedStreets if not already there
|
||||
await User.updateOne(
|
||||
{ _id: doc.adoptedBy },
|
||||
{ $addToSet: { adoptedStreets: doc._id } }
|
||||
);
|
||||
// Add pagination if specified
|
||||
if (filter.skip) query.skip = filter.skip;
|
||||
if (filter.limit) query.limit = filter.limit;
|
||||
|
||||
const docs = await couchdbService.find(query);
|
||||
|
||||
// Convert to Street instances
|
||||
return docs.map(doc => new Street(doc));
|
||||
} catch (error) {
|
||||
console.error("Error finding streets:", error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = mongoose.model("Street", StreetSchema);
|
||||
static async findById(id) {
|
||||
try {
|
||||
await couchdbService.initialize();
|
||||
const doc = await couchdbService.getDocument(id);
|
||||
|
||||
if (!doc || doc.type !== "street") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Street(doc);
|
||||
} catch (error) {
|
||||
if (error.statusCode === 404) {
|
||||
return null;
|
||||
}
|
||||
console.error("Error finding street by ID:", error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static async findOne(filter = {}) {
|
||||
try {
|
||||
const streets = await Street.find(filter);
|
||||
return streets.length > 0 ? streets[0] : null;
|
||||
} catch (error) {
|
||||
console.error("Error finding one street:", error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static async countDocuments(filter = {}) {
|
||||
try {
|
||||
await couchdbService.initialize();
|
||||
|
||||
const selector = { type: "street", ...filter };
|
||||
|
||||
// Use Mango query with count
|
||||
const query = {
|
||||
selector,
|
||||
fields: ["_id"]
|
||||
};
|
||||
|
||||
const docs = await couchdbService.find(query);
|
||||
return docs.length;
|
||||
} catch (error) {
|
||||
console.error("Error counting streets:", error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static async create(data) {
|
||||
try {
|
||||
await couchdbService.initialize();
|
||||
|
||||
const street = new Street(data);
|
||||
const doc = await couchdbService.createDocument(street.toJSON());
|
||||
|
||||
return new Street(doc);
|
||||
} catch (error) {
|
||||
console.error("Error creating street:", error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static async deleteMany(filter = {}) {
|
||||
try {
|
||||
await couchdbService.initialize();
|
||||
|
||||
const streets = await Street.find(filter);
|
||||
const deletePromises = streets.map(street => street.delete());
|
||||
|
||||
await Promise.all(deletePromises);
|
||||
return { deletedCount: streets.length };
|
||||
} catch (error) {
|
||||
console.error("Error deleting many streets:", error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Instance methods
|
||||
async save() {
|
||||
try {
|
||||
await couchdbService.initialize();
|
||||
|
||||
this.updatedAt = new Date().toISOString();
|
||||
|
||||
if (this._id && this._rev) {
|
||||
// Update existing document
|
||||
const doc = await couchdbService.updateDocument(this.toJSON());
|
||||
this._rev = doc._rev;
|
||||
} else {
|
||||
// Create new document
|
||||
const doc = await couchdbService.createDocument(this.toJSON());
|
||||
this._id = doc._id;
|
||||
this._rev = doc._rev;
|
||||
}
|
||||
|
||||
return this;
|
||||
} catch (error) {
|
||||
console.error("Error saving street:", error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async delete() {
|
||||
try {
|
||||
await couchdbService.initialize();
|
||||
|
||||
if (!this._id || !this._rev) {
|
||||
throw new Error("Street must have _id and _rev to delete");
|
||||
}
|
||||
|
||||
// Handle cascade operations
|
||||
await this._handleCascadeDelete();
|
||||
|
||||
await couchdbService.deleteDocument(this._id, this._rev);
|
||||
return this;
|
||||
} catch (error) {
|
||||
console.error("Error deleting street:", error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async _handleCascadeDelete() {
|
||||
try {
|
||||
// Remove street from user's adoptedStreets
|
||||
if (this.adoptedBy && this.adoptedBy.userId) {
|
||||
const User = require("./User");
|
||||
const user = await User.findById(this.adoptedBy.userId);
|
||||
|
||||
if (user) {
|
||||
user.adoptedStreets = user.adoptedStreets.filter(id => id !== this._id);
|
||||
await user.save();
|
||||
}
|
||||
}
|
||||
|
||||
// Delete all tasks associated with this street
|
||||
const Task = require("./Task");
|
||||
await Task.deleteMany({ "street.streetId": this._id });
|
||||
} catch (error) {
|
||||
console.error("Error handling cascade delete:", error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Populate method for compatibility
|
||||
async populate(path) {
|
||||
if (path === "adoptedBy" && this.adoptedBy && this.adoptedBy.userId) {
|
||||
const User = require("./User");
|
||||
const user = await User.findById(this.adoptedBy.userId);
|
||||
|
||||
if (user) {
|
||||
this.adoptedBy = {
|
||||
userId: user._id,
|
||||
name: user.name,
|
||||
profilePicture: user.profilePicture
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
// Geospatial query helper
|
||||
static async findNearby(coordinates, maxDistance = 1000) {
|
||||
try {
|
||||
await couchdbService.initialize();
|
||||
|
||||
// For CouchDB, we'll use a bounding box approach
|
||||
// Calculate bounding box around the point
|
||||
const [lng, lat] = coordinates;
|
||||
const earthRadius = 6371000; // Earth's radius in meters
|
||||
const latDelta = (maxDistance / earthRadius) * (180 / Math.PI);
|
||||
const lngDelta = (maxDistance / earthRadius) * (180 / Math.PI) / Math.cos(lat * Math.PI / 180);
|
||||
|
||||
const bounds = [
|
||||
[lng - lngDelta, lat - latDelta], // Southwest corner
|
||||
[lng + lngDelta, lat + latDelta] // Northeast corner
|
||||
];
|
||||
|
||||
const streets = await couchdbService.findStreetsByLocation(bounds);
|
||||
return streets.map(doc => new Street(doc));
|
||||
} catch (error) {
|
||||
console.error("Error finding nearby streets:", error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to plain object
|
||||
toJSON() {
|
||||
return {
|
||||
_id: this._id,
|
||||
_rev: this._rev,
|
||||
type: this.type,
|
||||
name: this.name,
|
||||
location: this.location,
|
||||
adoptedBy: this.adoptedBy,
|
||||
status: this.status,
|
||||
createdAt: this.createdAt,
|
||||
updatedAt: this.updatedAt,
|
||||
stats: this.stats
|
||||
};
|
||||
}
|
||||
|
||||
// Convert to MongoDB-like format for API responses
|
||||
toObject() {
|
||||
const obj = this.toJSON();
|
||||
|
||||
// Remove CouchDB-specific fields for API compatibility
|
||||
delete obj._rev;
|
||||
delete obj.type;
|
||||
|
||||
// Add _id field for compatibility
|
||||
if (obj._id) {
|
||||
obj.id = obj._id;
|
||||
}
|
||||
|
||||
return obj;
|
||||
}
|
||||
}
|
||||
|
||||
// Export both the class and static methods for compatibility
|
||||
module.exports = Street;
|
||||
|
||||
@@ -1,62 +1,312 @@
|
||||
const mongoose = require("mongoose");
|
||||
const couchdbService = require("../services/couchdbService");
|
||||
|
||||
const TaskSchema = new mongoose.Schema(
|
||||
{
|
||||
street: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: "Street",
|
||||
required: true,
|
||||
index: true,
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
completedBy: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: "User",
|
||||
index: true,
|
||||
},
|
||||
status: {
|
||||
type: String,
|
||||
enum: ["pending", "completed"],
|
||||
default: "pending",
|
||||
index: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
timestamps: true,
|
||||
},
|
||||
);
|
||||
|
||||
// Compound indexes for common queries
|
||||
TaskSchema.index({ street: 1, status: 1 });
|
||||
TaskSchema.index({ completedBy: 1, status: 1 });
|
||||
|
||||
// Update user relationship when task is completed
|
||||
TaskSchema.post("save", async function (doc) {
|
||||
if (doc.completedBy && doc.status === "completed") {
|
||||
const User = mongoose.model("User");
|
||||
|
||||
// Add task to user's completedTasks if not already there
|
||||
await User.updateOne(
|
||||
{ _id: doc.completedBy },
|
||||
{ $addToSet: { completedTasks: doc._id } }
|
||||
);
|
||||
class Task {
|
||||
constructor(data) {
|
||||
this._id = data._id || null;
|
||||
this._rev = data._rev || null;
|
||||
this.type = "task";
|
||||
this.street = data.street || null;
|
||||
this.description = data.description;
|
||||
this.completedBy = data.completedBy || null;
|
||||
this.status = data.status || "pending";
|
||||
this.pointsAwarded = data.pointsAwarded || 10;
|
||||
this.createdAt = data.createdAt || new Date().toISOString();
|
||||
this.updatedAt = data.updatedAt || new Date().toISOString();
|
||||
this.completedAt = data.completedAt || null;
|
||||
}
|
||||
});
|
||||
|
||||
// Cascade cleanup when a task is deleted
|
||||
TaskSchema.pre("deleteOne", { document: true, query: false }, async function () {
|
||||
const User = mongoose.model("User");
|
||||
// Static methods for MongoDB-like interface
|
||||
static async find(filter = {}) {
|
||||
try {
|
||||
await couchdbService.initialize();
|
||||
|
||||
// Convert MongoDB filter to CouchDB selector
|
||||
const selector = { type: "task", ...filter };
|
||||
|
||||
// Handle special cases
|
||||
if (filter._id) {
|
||||
selector._id = filter._id;
|
||||
}
|
||||
|
||||
if (filter.status) {
|
||||
selector.status = filter.status;
|
||||
}
|
||||
|
||||
if (filter.street) {
|
||||
selector["street.streetId"] = filter.street;
|
||||
}
|
||||
|
||||
if (filter.completedBy) {
|
||||
selector["completedBy.userId"] = filter.completedBy;
|
||||
}
|
||||
|
||||
// Remove task from user's completedTasks
|
||||
if (this.completedBy) {
|
||||
await User.updateOne(
|
||||
{ _id: this.completedBy },
|
||||
{ $pull: { completedTasks: this._id } }
|
||||
);
|
||||
const query = {
|
||||
selector,
|
||||
sort: filter.sort || [{ createdAt: "desc" }]
|
||||
};
|
||||
|
||||
// Add pagination if specified
|
||||
if (filter.skip) query.skip = filter.skip;
|
||||
if (filter.limit) query.limit = filter.limit;
|
||||
|
||||
const docs = await couchdbService.find(query);
|
||||
|
||||
// Convert to Task instances
|
||||
return docs.map(doc => new Task(doc));
|
||||
} catch (error) {
|
||||
console.error("Error finding tasks:", error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = mongoose.model("Task", TaskSchema);
|
||||
static async findById(id) {
|
||||
try {
|
||||
await couchdbService.initialize();
|
||||
const doc = await couchdbService.getDocument(id);
|
||||
|
||||
if (!doc || doc.type !== "task") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Task(doc);
|
||||
} catch (error) {
|
||||
if (error.statusCode === 404) {
|
||||
return null;
|
||||
}
|
||||
console.error("Error finding task by ID:", error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static async findOne(filter = {}) {
|
||||
try {
|
||||
const tasks = await Task.find(filter);
|
||||
return tasks.length > 0 ? tasks[0] : null;
|
||||
} catch (error) {
|
||||
console.error("Error finding one task:", error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static async countDocuments(filter = {}) {
|
||||
try {
|
||||
await couchdbService.initialize();
|
||||
|
||||
const selector = { type: "task", ...filter };
|
||||
|
||||
// Use Mango query with count
|
||||
const query = {
|
||||
selector,
|
||||
fields: ["_id"]
|
||||
};
|
||||
|
||||
const docs = await couchdbService.find(query);
|
||||
return docs.length;
|
||||
} catch (error) {
|
||||
console.error("Error counting tasks:", error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static async create(data) {
|
||||
try {
|
||||
await couchdbService.initialize();
|
||||
|
||||
const task = new Task(data);
|
||||
const doc = await couchdbService.createDocument(task.toJSON());
|
||||
|
||||
return new Task(doc);
|
||||
} catch (error) {
|
||||
console.error("Error creating task:", error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static async deleteMany(filter = {}) {
|
||||
try {
|
||||
await couchdbService.initialize();
|
||||
|
||||
const tasks = await Task.find(filter);
|
||||
const deletePromises = tasks.map(task => task.delete());
|
||||
|
||||
await Promise.all(deletePromises);
|
||||
return { deletedCount: tasks.length };
|
||||
} catch (error) {
|
||||
console.error("Error deleting many tasks:", error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Instance methods
|
||||
async save() {
|
||||
try {
|
||||
await couchdbService.initialize();
|
||||
|
||||
this.updatedAt = new Date().toISOString();
|
||||
|
||||
if (this._id && this._rev) {
|
||||
// Update existing document
|
||||
const doc = await couchdbService.updateDocument(this.toJSON());
|
||||
this._rev = doc._rev;
|
||||
} else {
|
||||
// Create new document
|
||||
const doc = await couchdbService.createDocument(this.toJSON());
|
||||
this._id = doc._id;
|
||||
this._rev = doc._rev;
|
||||
}
|
||||
|
||||
// Handle post-save operations
|
||||
await this._handlePostSave();
|
||||
|
||||
return this;
|
||||
} catch (error) {
|
||||
console.error("Error saving task:", error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async delete() {
|
||||
try {
|
||||
await couchdbService.initialize();
|
||||
|
||||
if (!this._id || !this._rev) {
|
||||
throw new Error("Task must have _id and _rev to delete");
|
||||
}
|
||||
|
||||
// Handle cascade operations
|
||||
await this._handleCascadeDelete();
|
||||
|
||||
await couchdbService.deleteDocument(this._id, this._rev);
|
||||
return this;
|
||||
} catch (error) {
|
||||
console.error("Error deleting task:", error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async _handlePostSave() {
|
||||
try {
|
||||
// Update user relationship when task is completed
|
||||
if (this.completedBy && this.completedBy.userId && this.status === "completed") {
|
||||
const User = require("./User");
|
||||
const user = await User.findById(this.completedBy.userId);
|
||||
|
||||
if (user && !user.completedTasks.includes(this._id)) {
|
||||
user.completedTasks.push(this._id);
|
||||
user.stats.tasksCompleted = user.completedTasks.length;
|
||||
await user.save();
|
||||
}
|
||||
|
||||
// Update street stats
|
||||
if (this.street && this.street.streetId) {
|
||||
const Street = require("./Street");
|
||||
const street = await Street.findById(this.street.streetId);
|
||||
|
||||
if (street) {
|
||||
street.stats.completedTasksCount = (street.stats.completedTasksCount || 0) + 1;
|
||||
await street.save();
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error handling post-save:", error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async _handleCascadeDelete() {
|
||||
try {
|
||||
// Remove task from user's completedTasks
|
||||
if (this.completedBy && this.completedBy.userId) {
|
||||
const User = require("./User");
|
||||
const user = await User.findById(this.completedBy.userId);
|
||||
|
||||
if (user) {
|
||||
user.completedTasks = user.completedTasks.filter(id => id !== this._id);
|
||||
user.stats.tasksCompleted = user.completedTasks.length;
|
||||
await user.save();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error handling cascade delete:", error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Populate method for compatibility
|
||||
async populate(paths) {
|
||||
if (Array.isArray(paths)) {
|
||||
for (const path of paths) {
|
||||
await this._populatePath(path);
|
||||
}
|
||||
} else {
|
||||
await this._populatePath(paths);
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
async _populatePath(path) {
|
||||
if (path === "street" && this.street && this.street.streetId) {
|
||||
const Street = require("./Street");
|
||||
const street = await Street.findById(this.street.streetId);
|
||||
|
||||
if (street) {
|
||||
this.street = {
|
||||
streetId: street._id,
|
||||
name: street.name,
|
||||
location: street.location
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (path === "completedBy" && this.completedBy && this.completedBy.userId) {
|
||||
const User = require("./User");
|
||||
const user = await User.findById(this.completedBy.userId);
|
||||
|
||||
if (user) {
|
||||
this.completedBy = {
|
||||
userId: user._id,
|
||||
name: user.name,
|
||||
profilePicture: user.profilePicture
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to plain object
|
||||
toJSON() {
|
||||
return {
|
||||
_id: this._id,
|
||||
_rev: this._rev,
|
||||
type: this.type,
|
||||
street: this.street,
|
||||
description: this.description,
|
||||
completedBy: this.completedBy,
|
||||
status: this.status,
|
||||
pointsAwarded: this.pointsAwarded,
|
||||
createdAt: this.createdAt,
|
||||
updatedAt: this.updatedAt,
|
||||
completedAt: this.completedAt
|
||||
};
|
||||
}
|
||||
|
||||
// Convert to MongoDB-like format for API responses
|
||||
toObject() {
|
||||
const obj = this.toJSON();
|
||||
|
||||
// Remove CouchDB-specific fields for API compatibility
|
||||
delete obj._rev;
|
||||
delete obj.type;
|
||||
|
||||
// Add _id field for compatibility
|
||||
if (obj._id) {
|
||||
obj.id = obj._id;
|
||||
}
|
||||
|
||||
return obj;
|
||||
}
|
||||
}
|
||||
|
||||
// Export both the class and static methods for compatibility
|
||||
module.exports = Task;
|
||||
|
||||
@@ -1,78 +1,204 @@
|
||||
const mongoose = require("mongoose");
|
||||
const bcrypt = require("bcryptjs");
|
||||
const couchdbService = require("../services/couchdbService");
|
||||
|
||||
const UserSchema = new mongoose.Schema(
|
||||
{
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
email: {
|
||||
type: String,
|
||||
required: true,
|
||||
unique: true,
|
||||
},
|
||||
password: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
isPremium: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
points: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
min: 0,
|
||||
},
|
||||
adoptedStreets: [
|
||||
{
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: "Street",
|
||||
},
|
||||
],
|
||||
completedTasks: [
|
||||
{
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: "Task",
|
||||
},
|
||||
],
|
||||
posts: [
|
||||
{
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: "Post",
|
||||
},
|
||||
],
|
||||
events: [
|
||||
{
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: "Event",
|
||||
},
|
||||
],
|
||||
profilePicture: {
|
||||
type: String,
|
||||
},
|
||||
cloudinaryPublicId: {
|
||||
type: String,
|
||||
},
|
||||
},
|
||||
{
|
||||
timestamps: true,
|
||||
},
|
||||
);
|
||||
class User {
|
||||
constructor(data) {
|
||||
// Validate required fields
|
||||
if (!data.name) {
|
||||
throw new Error('Name is required');
|
||||
}
|
||||
if (!data.email) {
|
||||
throw new Error('Email is required');
|
||||
}
|
||||
if (!data.password) {
|
||||
throw new Error('Password is required');
|
||||
}
|
||||
|
||||
// Indexes for performance
|
||||
UserSchema.index({ email: 1 });
|
||||
UserSchema.index({ points: -1 }); // For leaderboards
|
||||
this._id = data._id || null;
|
||||
this._rev = data._rev || null;
|
||||
this.type = "user";
|
||||
this.name = data.name;
|
||||
this.email = data.email;
|
||||
this.password = data.password;
|
||||
this.isPremium = data.isPremium || false;
|
||||
this.points = Math.max(0, data.points || 0); // Ensure non-negative
|
||||
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
|
||||
};
|
||||
this.createdAt = data.createdAt || new Date().toISOString();
|
||||
this.updatedAt = data.updatedAt || new Date().toISOString();
|
||||
}
|
||||
|
||||
// Virtual for earned badges (populated from UserBadge collection)
|
||||
UserSchema.virtual("earnedBadges", {
|
||||
ref: "UserBadge",
|
||||
localField: "_id",
|
||||
foreignField: "user",
|
||||
});
|
||||
// Static methods for MongoDB compatibility
|
||||
static async findOne(query) {
|
||||
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({
|
||||
selector: { type: "user", ...query },
|
||||
limit: 1
|
||||
});
|
||||
user = docs[0] || null;
|
||||
}
|
||||
return user ? new User(user) : null;
|
||||
}
|
||||
|
||||
// Ensure virtuals are included when converting to JSON
|
||||
UserSchema.set("toJSON", { virtuals: true });
|
||||
UserSchema.set("toObject", { virtuals: true });
|
||||
static async findById(id) {
|
||||
const user = await couchdbService.findUserById(id);
|
||||
return user ? new User(user) : null;
|
||||
}
|
||||
|
||||
module.exports = mongoose.model("User", UserSchema);
|
||||
static async findByIdAndUpdate(id, update, options = {}) {
|
||||
const user = await couchdbService.findUserById(id);
|
||||
if (!user) return null;
|
||||
|
||||
const updatedUser = { ...user, ...update, updatedAt: new Date().toISOString() };
|
||||
const saved = await couchdbService.update(id, updatedUser);
|
||||
|
||||
if (options.new) {
|
||||
return saved;
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
static async findByIdAndDelete(id) {
|
||||
const user = await couchdbService.findUserById(id);
|
||||
if (!user) return null;
|
||||
|
||||
await couchdbService.delete(id);
|
||||
return user;
|
||||
}
|
||||
|
||||
static async find(query = {}) {
|
||||
const selector = { type: "user", ...query };
|
||||
return await couchdbService.find({ selector });
|
||||
}
|
||||
|
||||
static async create(userData) {
|
||||
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);
|
||||
}
|
||||
|
||||
// Generate ID if not provided
|
||||
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);
|
||||
}
|
||||
|
||||
// Instance methods
|
||||
async save() {
|
||||
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')) {
|
||||
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());
|
||||
this._rev = updated._rev;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
async comparePassword(candidatePassword) {
|
||||
return await bcrypt.compare(candidatePassword, this.password);
|
||||
}
|
||||
|
||||
// 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,
|
||||
_rev: this._rev,
|
||||
type: this.type,
|
||||
name: this.name,
|
||||
email: this.email,
|
||||
password: this.password,
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
// Static method for select functionality
|
||||
static async select(fields) {
|
||||
const users = await couchdbService.find({
|
||||
selector: { type: "user" },
|
||||
fields: fields
|
||||
});
|
||||
return users.map(user => new User(user));
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
Reference in New Issue
Block a user