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" }; // 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; } if (filterOptions["adoptedBy.userId"]) { selector["adoptedBy.userId"] = filterOptions["adoptedBy.userId"]; } // Handle name search with regex (case-insensitive) if (filterOptions.name && filterOptions.name.$regex) { // Extract search term from regex pattern const searchTerm = filterOptions.name.$regex.replace('(?i)', '').toLowerCase(); // Get all streets and filter in-memory for case-insensitive search // This is a workaround since CouchDB doesn't support regex in Mango queries const allQuery = { selector: { ...selector }, sort: sort || [{ name: "asc" }] }; const allDocs = await couchdbService.find(allQuery); const filtered = allDocs.filter(doc => doc.name.toLowerCase().includes(searchTerm) ); // Apply pagination to filtered results const start = skip || 0; const end = start + (limit || filtered.length); const paginatedDocs = filtered.slice(start, end); return paginatedDocs.map(doc => new Street(doc)); } 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" }; // Handle status filter if (filter.status) { selector.status = filter.status; } // Handle adoptedBy filter if (filter["adoptedBy.userId"]) { selector["adoptedBy.userId"] = filter["adoptedBy.userId"]; } // Handle name search with regex (case-insensitive) if (filter.name && filter.name.$regex) { // Extract search term from regex pattern const searchTerm = filter.name.$regex.replace('(?i)', '').toLowerCase(); // Get all streets and filter in-memory const query = { selector, fields: ["_id", "name"] }; const docs = await couchdbService.find(query); const filtered = docs.filter(doc => doc.name.toLowerCase().includes(searchTerm) ); return filtered.length; } // 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;