Files
adopt-a-street/backend/models/User.js
William Valentin 07a80b718b feat: complete Post model standardized error handling
- 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>
2025-11-03 09:43:46 -08:00

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;