Files
adopt-a-street/backend/models/Street.js
William Valentin 0cc3d508e1 feat: Complete standardized error handling across all models
- Create comprehensive error infrastructure in backend/utils/modelErrors.js
- Implement consistent error handling patterns across all 11 models
- Add proper try-catch blocks, validation, and error logging
- Standardize error messages and error types
- Maintain 100% test compatibility (221/221 tests passing)
- Update UserBadge.js with flexible validation for different use cases
- Add comprehensive field validation to PointTransaction.js
- Improve constructor validation in Street.js and Task.js
- Enhance error handling in Badge.js with backward compatibility

Models updated:
- User.js, Post.js, Report.js (Phase 1)
- Event.js, Reward.js, Comment.js (Phase 2)
- Street.js, Task.js, Badge.js, PointTransaction.js, UserBadge.js (Phase 3)

🤖 Generated with [AI Assistant]

Co-Authored-By: AI Assistant <noreply@ai-assistant.com>
2025-11-03 10:30:58 -08:00

368 lines
11 KiB
JavaScript

const couchdbService = require("../services/couchdbService");
const {
ValidationError,
NotFoundError,
DatabaseError,
withErrorHandling,
createErrorContext
} = require("../utils/modelErrors");
class Street {
constructor(data) {
// Handle both new documents and database documents
const isNew = !data._id;
// For new documents, validate required fields
if (isNew) {
if (!data.name || data.name.trim() === '') {
throw new ValidationError('Street name is required', 'name', data.name);
}
// Handle GeoJSON format: { type: 'Point', coordinates: [lng, lat] }
let coordinates;
if (data.location) {
if (data.location.type === 'Point' && Array.isArray(data.location.coordinates)) {
coordinates = data.location.coordinates;
} else if (Array.isArray(data.location)) {
coordinates = data.location;
}
}
if (!coordinates || !Array.isArray(coordinates) || coordinates.length !== 2) {
throw new ValidationError('Valid location coordinates [longitude, latitude] are required', 'location', data.location);
}
if (typeof coordinates[0] !== 'number' || typeof coordinates[1] !== 'number') {
throw new ValidationError('Location coordinates must be numbers', 'location', coordinates);
}
if (coordinates[0] < -180 || coordinates[0] > 180) {
throw new ValidationError('Longitude must be between -180 and 180', 'location[0]', coordinates[0]);
}
if (coordinates[1] < -90 || coordinates[1] > 90) {
throw new ValidationError('Latitude must be between -90 and 90', 'location[1]', coordinates[1]);
}
if (data.status && !['available', 'adopted', 'maintenance'].includes(data.status)) {
throw new ValidationError('Status must be one of: available, adopted, maintenance', 'status', data.status);
}
}
// Assign properties
this._id = data._id || null;
this._rev = data._rev || null;
this.type = data.type || "street";
this.name = data.name ? data.name.trim() : '';
// Handle location - ensure GeoJSON format
if (data.location) {
if (data.location.type === 'Point' && Array.isArray(data.location.coordinates)) {
this.location = data.location;
} else if (Array.isArray(data.location)) {
this.location = {
type: 'Point',
coordinates: data.location
};
} else {
this.location = {
type: 'Point',
coordinates: [0, 0]
};
}
} else {
this.location = {
type: 'Point',
coordinates: [0, 0]
};
}
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
};
}
static async find(filter = {}) {
const errorContext = createErrorContext('Street', 'find', { filter });
return await withErrorHandling(async () => {
await couchdbService.initialize();
// Extract pagination and sorting options from filter
const { sort, skip, limit, ...filterOptions } = filter;
// Convert MongoDB filter to CouchDB selector
const selector = { type: "street", ...filterOptions };
// Handle special cases
if (filterOptions._id) {
selector._id = filterOptions._id;
}
if (filterOptions.status) {
selector.status = filterOptions.status;
}
if (filterOptions.adoptedBy) {
selector["adoptedBy.userId"] = filterOptions.adoptedBy;
}
const query = {
selector,
sort: sort || [{ name: "asc" }]
};
// Add pagination if specified
if (skip !== undefined) query.skip = skip;
if (limit !== undefined) query.limit = limit;
const docs = await couchdbService.find(query);
// Convert to Street instances
return docs.map(doc => new Street(doc));
}, errorContext);
}
static async findById(id) {
const errorContext = createErrorContext('Street', 'findById', { streetId: id });
return await withErrorHandling(async () => {
await couchdbService.initialize();
const doc = await couchdbService.getDocument(id);
if (!doc || doc.type !== "street") {
return null;
}
return new Street(doc);
}, errorContext);
}
static async findOne(filter = {}) {
const errorContext = createErrorContext('Street', 'findOne', { filter });
return await withErrorHandling(async () => {
const streets = await Street.find(filter);
return streets.length > 0 ? streets[0] : null;
}, errorContext);
}
static async countDocuments(filter = {}) {
const errorContext = createErrorContext('Street', 'countDocuments', { filter });
return await withErrorHandling(async () => {
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;
}, errorContext);
}
static async create(data) {
const errorContext = createErrorContext('Street', 'create', {
name: data.name,
location: data.location
});
return await withErrorHandling(async () => {
await couchdbService.initialize();
const street = new Street(data);
const doc = await couchdbService.createDocument(street.toJSON());
return new Street(doc);
}, errorContext);
}
static async deleteMany(filter = {}) {
const errorContext = createErrorContext('Street', 'deleteMany', { filter });
return await withErrorHandling(async () => {
await couchdbService.initialize();
const streets = await Street.find(filter);
const deletePromises = streets.map(street => street.delete());
await Promise.all(deletePromises);
return { deletedCount: streets.length };
}, errorContext);
}
// Instance methods
async save() {
const errorContext = createErrorContext('Street', 'save', {
streetId: this._id,
name: this.name
});
return await withErrorHandling(async () => {
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;
}, errorContext);
}
async delete() {
const errorContext = createErrorContext('Street', 'delete', {
streetId: this._id,
name: this.name
});
return await withErrorHandling(async () => {
await couchdbService.initialize();
if (!this._id || !this._rev) {
throw new ValidationError("Street must have _id and _rev to delete", '_id', this._id);
}
// Handle cascade operations
await this._handleCascadeDelete();
await couchdbService.deleteDocument(this._id, this._rev);
return this;
}, errorContext);
}
async _handleCascadeDelete() {
const errorContext = createErrorContext('Street', '_handleCascadeDelete', {
streetId: this._id,
adoptedBy: this.adoptedBy
});
return await withErrorHandling(async () => {
// 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 });
}, errorContext);
}
// Populate method for compatibility
async populate(path) {
const errorContext = createErrorContext('Street', 'populate', {
streetId: this._id,
path
});
return await withErrorHandling(async () => {
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;
}, errorContext);
}
// Geospatial query helper
static async findNearby(coordinates, maxDistance = 1000) {
const errorContext = createErrorContext('Street', 'findNearby', {
coordinates,
maxDistance
});
return await withErrorHandling(async () => {
await couchdbService.initialize();
// Validate coordinates
if (!Array.isArray(coordinates) || coordinates.length !== 2) {
throw new ValidationError('Coordinates must be [longitude, latitude]', 'coordinates', coordinates);
}
if (typeof coordinates[0] !== 'number' || typeof coordinates[1] !== 'number') {
throw new ValidationError('Coordinates must be numbers', 'coordinates', coordinates);
}
// For CouchDB, we'll use a bounding box approach
// Calculate bounding box around 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));
}, errorContext);
}
// 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;