- Add comprehensive error handling to Post model with ValidationError, NotFoundError - Fix Post model toJSON method duplicate type field bug - Update Post test suite with proper mocking for all CouchDB service methods - All 23 Post model tests now passing - Complete standardized error handling implementation for User, Report, and Post models - Add modelErrors utility with structured error classes and logging 🤖 Generated with AI Assistant Co-Authored-By: AI Assistant <noreply@ai-assistant.com>
256 lines
7.5 KiB
JavaScript
256 lines
7.5 KiB
JavaScript
const bcrypt = require("bcryptjs");
|
|
const couchdbService = require("../services/couchdbService");
|
|
const {
|
|
ValidationError,
|
|
NotFoundError,
|
|
DatabaseError,
|
|
DuplicateError,
|
|
withErrorHandling,
|
|
createErrorContext
|
|
} = require("../utils/modelErrors");
|
|
|
|
class User {
|
|
constructor(data) {
|
|
// Validate required fields
|
|
if (!data.name) {
|
|
throw new ValidationError('Name is required', 'name', data.name);
|
|
}
|
|
if (!data.email) {
|
|
throw new ValidationError('Email is required', 'email', data.email);
|
|
}
|
|
if (!data.password) {
|
|
throw new ValidationError('Password is required', 'password', data.password);
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
// Static methods for MongoDB compatibility
|
|
static async findOne(query) {
|
|
const errorContext = createErrorContext('User', 'findOne', { query });
|
|
|
|
return await withErrorHandling(async () => {
|
|
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;
|
|
}, errorContext);
|
|
}
|
|
|
|
static async findById(id) {
|
|
const errorContext = createErrorContext('User', 'findById', { id });
|
|
|
|
return await withErrorHandling(async () => {
|
|
const user = await couchdbService.findUserById(id);
|
|
return user ? new User(user) : null;
|
|
}, errorContext);
|
|
}
|
|
|
|
static async findByIdAndUpdate(id, update, options = {}) {
|
|
const errorContext = createErrorContext('User', 'findByIdAndUpdate', { id, update, options });
|
|
|
|
return await withErrorHandling(async () => {
|
|
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;
|
|
}, errorContext);
|
|
}
|
|
|
|
static async findByIdAndDelete(id) {
|
|
const errorContext = createErrorContext('User', 'findByIdAndDelete', { id });
|
|
|
|
return await withErrorHandling(async () => {
|
|
const user = await couchdbService.findUserById(id);
|
|
if (!user) return null;
|
|
|
|
await couchdbService.delete(id);
|
|
return user;
|
|
}, errorContext);
|
|
}
|
|
|
|
static async find(query = {}) {
|
|
const errorContext = createErrorContext('User', 'find', { query });
|
|
|
|
return await withErrorHandling(async () => {
|
|
const selector = { type: "user", ...query };
|
|
return await couchdbService.find({ selector });
|
|
}, errorContext);
|
|
}
|
|
|
|
static async create(userData) {
|
|
const errorContext = createErrorContext('User', 'create', { userData: { ...userData, password: '[REDACTED]' } });
|
|
|
|
return await withErrorHandling(async () => {
|
|
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);
|
|
}, errorContext);
|
|
}
|
|
|
|
// Instance methods
|
|
async save() {
|
|
const errorContext = createErrorContext('User', 'save', {
|
|
id: this._id,
|
|
email: this.email,
|
|
isNew: !this._id
|
|
});
|
|
|
|
return await withErrorHandling(async () => {
|
|
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;
|
|
}
|
|
}, errorContext);
|
|
}
|
|
|
|
async comparePassword(candidatePassword) {
|
|
const errorContext = createErrorContext('User', 'comparePassword', {
|
|
id: this._id,
|
|
email: this.email
|
|
});
|
|
|
|
return await withErrorHandling(async () => {
|
|
return await bcrypt.compare(candidatePassword, this.password);
|
|
}, errorContext);
|
|
}
|
|
|
|
// 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 errorContext = createErrorContext('User', 'select', { fields });
|
|
|
|
return await withErrorHandling(async () => {
|
|
const users = await couchdbService.find({
|
|
selector: { type: "user" },
|
|
fields: fields
|
|
});
|
|
return users.map(user => new User(user));
|
|
}, errorContext);
|
|
}
|
|
}
|
|
|
|
// 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;
|