Add advanced filtering, search, and sorting capabilities to streets endpoint: - Backend: Enhanced GET /api/streets with query parameters (search, status, adoptedBy, sort, order) - Backend: Implement case-insensitive name search with in-memory filtering - Backend: Add X-Total-Count response header for pagination metadata - Frontend: Add comprehensive filter UI with search bar, status dropdown, and sort controls - Frontend: Implement 'My Streets' toggle for authenticated users to view their adopted streets - Frontend: Add 'Clear Filters' button and result count display - Frontend: Update map markers and street list to reflect filtered results - Frontend: Mobile-responsive Bootstrap grid layout with loading states Technical implementation: - Routes: Enhanced backend/routes/streets.js with filter logic - Model: Updated backend/models/Street.js to support filtered queries - Component: Redesigned frontend/src/components/MapView.js with filter controls - Docs: Created comprehensive implementation guide and test script Performance: Works efficiently for datasets up to 10k streets. Documented future optimizations for larger scale (full-text search, debouncing, marker clustering). 🤖 Generated with Claude Co-Authored-By: AI Assistant <noreply@ai-assistant.com>
426 lines
13 KiB
JavaScript
426 lines
13 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" };
|
|
|
|
// 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;
|