const nano = require("nano"); class CouchDBService { constructor() { this.connection = null; this.db = null; this.dbName = null; this.isConnected = false; this.isConnecting = false; } /** * Initialize CouchDB connection and database */ async initialize() { if (this.isConnected || this.isConnecting) { return this.isConnected; } this.isConnecting = true; try { // Get configuration from environment variables const couchdbUrl = process.env.COUCHDB_URL || "http://localhost:5984"; this.dbName = process.env.COUCHDB_DB_NAME || "adopt-a-street"; console.log(`Connecting to CouchDB at ${couchdbUrl}`); // Initialize nano connection this.connection = nano(couchdbUrl); // Test connection await this.connection.info(); console.log("CouchDB connection established"); // Get or create database this.db = this.connection.use(this.dbName); try { await this.db.info(); console.log(`Database '${this.dbName}' exists`); } catch (error) { if (error.statusCode === 404) { console.log(`Creating database '${this.dbName}'`); await this.connection.db.create(this.dbName); this.db = this.connection.use(this.dbName); console.log(`Database '${this.dbName}' created successfully`); } else { throw error; } } // Initialize design documents and indexes await this.initializeDesignDocuments(); this.isConnected = true; console.log("CouchDB service initialized successfully"); return true; } catch (error) { console.error("Failed to initialize CouchDB:", error.message); this.isConnected = false; this.isConnecting = false; throw error; } finally { this.isConnecting = false; } } /** * Initialize design documents with indexes and views */ async initializeDesignDocuments() { const designDocs = [ { _id: "_design/users", views: { "by-email": { map: `function(doc) { if (doc.type === "user" && doc.email) { emit(doc.email, null); } }` }, "by-points": { map: `function(doc) { if (doc.type === "user" && doc.points > 0) { emit(doc.points, doc); } }` } }, indexes: { "user-by-email": { index: { fields: ["type", "email"] }, name: "user-by-email", type: "json" }, "users-by-points": { index: { fields: ["type", "points"] }, name: "users-by-points", type: "json" } } }, { _id: "_design/streets", views: { "by-status": { map: `function(doc) { if (doc.type === "street" && doc.status) { emit(doc.status, doc); } }` }, "by-adopter": { map: `function(doc) { if (doc.type === "street" && doc.adoptedBy && doc.adoptedBy.userId) { emit(doc.adoptedBy.userId, doc); } }` } }, indexes: { "streets-by-location": { index: { fields: ["type", "location"], partial_filter_selector: { "type": "street" } }, name: "streets-by-location", type: "json" }, "streets-by-status": { index: { fields: ["type", "status"], partial_filter_selector: { "type": "street" } }, name: "streets-by-status", type: "json" }, "streets-geo": { index: { fields: ["type", "location"], partial_filter_selector: { "type": "street", "location": { "$exists": true } } }, name: "streets-geo", type: "json" } } }, { _id: "_design/tasks", views: { "by-street": { map: `function(doc) { if (doc.type === "task" && doc.street && doc.street.streetId) { emit(doc.street.streetId, doc); } }` }, "by-completer": { map: `function(doc) { if (doc.type === "task" && doc.completedBy && doc.completedBy.userId) { emit(doc.completedBy.userId, doc); } }` }, "by-status": { map: `function(doc) { if (doc.type === "task" && doc.status) { emit(doc.status, doc); } }` } }, indexes: { "tasks-by-user": { index: { fields: ["type", "completedBy.userId"] }, name: "tasks-by-user", type: "json" } } }, { _id: "_design/posts", views: { "by-user": { map: `function(doc) { if (doc.type === "post" && doc.user && doc.user.userId) { emit(doc.user.userId, doc); } }` }, "by-date": { map: `function(doc) { if (doc.type === "post" && doc.createdAt) { emit(doc.createdAt, doc); } }` } }, indexes: { "posts-by-date": { index: { fields: ["type", "createdAt"] }, name: "posts-by-date", type: "json" } } }, { _id: "_design/comments", views: { "by-post": { map: `function(doc) { if (doc.type === "comment" && doc.post && doc.post.postId) { emit(doc.post.postId, doc); } }` }, "by-user": { map: `function(doc) { if (doc.type === "comment" && doc.user && doc.user.userId) { emit(doc.user.userId, doc); } }` } }, indexes: { "comments-by-post": { index: { fields: ["type", "post.postId"] }, name: "comments-by-post", type: "json" } } }, { _id: "_design/events", views: { "by-date": { map: `function(doc) { if (doc.type === "event" && doc.date) { emit(doc.date, doc); } }` }, "by-participant": { map: `function(doc) { if (doc.type === "event" && doc.participants) { doc.participants.forEach(function(participant) { emit(participant.userId, doc); }); } }` } }, indexes: { "events-by-date-status": { index: { fields: ["type", "date", "status"] }, name: "events-by-date-status", type: "json" } } }, { _id: "_design/reports", views: { "by-street": { map: `function(doc) { if (doc.type === "report" && doc.street && doc.street.streetId) { emit(doc.street.streetId, doc); } }` }, "by-status": { map: `function(doc) { if (doc.type === "report" && doc.status) { emit(doc.status, doc); } }` } }, indexes: { "reports-by-status": { index: { fields: ["type", "status"] }, name: "reports-by-status", type: "json" } } }, { _id: "_design/badges", views: { "active-badges": { map: `function(doc) { if (doc.type === "badge" && doc.isActive) { emit(doc.order, doc); } }` } } }, { _id: "_design/transactions", views: { "by-user": { map: `function(doc) { if (doc.type === "point_transaction" && doc.user && doc.user.userId) { emit(doc.user.userId, doc); } }` }, "by-date": { map: `function(doc) { if (doc.type === "point_transaction" && doc.createdAt) { emit(doc.createdAt, doc); } }` } } }, { _id: "_design/rewards", views: { "by-cost": { map: `function(doc) { if (doc.type === "reward" && doc.cost) { emit(doc.cost, doc); } }` }, "by-premium": { map: `function(doc) { if (doc.type === "reward" && doc.isPremium) { emit(doc.isPremium, doc); } }` } }, indexes: { "rewards-by-cost": { index: { fields: ["type", "cost"] }, name: "rewards-by-cost", type: "json" }, "rewards-by-premium": { index: { fields: ["type", "isPremium"] }, name: "rewards-by-premium", type: "json" } } }, { _id: "_design/general", indexes: { "by-type": { index: { fields: ["type"] }, name: "by-type", type: "json" }, "by-user": { index: { fields: ["type", "user.userId"] }, name: "by-user", type: "json" }, "by-created-date": { index: { fields: ["type", "createdAt"] }, name: "by-created-date", type: "json" } } } ]; for (const designDoc of designDocs) { try { // Check if design document exists const existing = await this.db.get(designDoc._id); // Update with new revision designDoc._rev = existing._rev; await this.db.insert(designDoc); console.log(`Updated design document: ${designDoc._id}`); } catch (error) { if (error.statusCode === 404) { // Create new design document await this.db.insert(designDoc); console.log(`Created design document: ${designDoc._id}`); } else { console.error(`Error creating design document ${designDoc._id}:`, error.message); } } } } /** * Get database instance */ getDB() { if (!this.isConnected) { throw new Error("CouchDB not connected. Call initialize() first."); } return this.db; } /** * Check connection status */ isReady() { return this.isConnected; } /** * Check connection health */ async checkConnection() { try { if (!this.connection) { return false; } await this.connection.info(); return true; } catch (error) { console.error("CouchDB connection check failed:", error.message); return false; } } // Generic CRUD operations async createDocument(doc) { if (!this.isConnected) await this.initialize(); try { const response = await this.db.insert(doc); return { ...doc, _id: response.id, _rev: response.rev }; } catch (error) { console.error("Error creating document:", error.message); throw error; } } async getDocument(id, options = {}) { if (!this.isConnected) await this.initialize(); try { const doc = await this.db.get(id, options); return doc; } catch (error) { if (error.statusCode === 404) { return null; } console.error("Error getting document:", error.message); throw error; } } async updateDocument(doc) { if (!this.isConnected) await this.initialize(); try { if (!doc._id || !doc._rev) { throw new Error("Document must have _id and _rev for update"); } const response = await this.db.insert(doc); return { ...doc, _rev: response.rev }; } catch (error) { console.error("Error updating document:", error.message); throw error; } } async deleteDocument(id, rev) { if (!this.isConnected) await this.initialize(); try { const response = await this.db.destroy(id, rev); return response; } catch (error) { console.error("Error deleting document:", error.message); throw error; } } // Query operations async find(query) { if (!this.isConnected) await this.initialize(); try { const response = await this.db.find(query); return response.docs; } catch (error) { console.error("Error executing query:", error.message); throw error; } } async findOne(selector) { const query = { selector, limit: 1 }; const docs = await this.find(query); return docs[0] || null; } async findByType(type, selector = {}, options = {}) { const query = { selector: { type, ...selector }, ...options }; return await this.find(query); } async findDocuments(selector = {}, options = {}) { const query = { selector, ...options }; return await this.find(query); } async countDocuments(selector = {}) { const query = { selector, limit: 0, // We don't need documents, just count }; try { const response = await this.db.find(query); return response.total_rows || 0; } catch (error) { console.error("Error counting documents:", error.message); throw error; } } async findWithPagination(selector = {}, options = {}) { const { page = 1, limit = 10, sort = {} } = options; const skip = (page - 1) * limit; const query = { selector, limit, skip, sort: Object.entries(sort).map(([field, order]) => ({ [field]: order === -1 ? "desc" : "asc" })) }; try { const response = await this.db.find(query); const totalCount = response.total_rows || 0; const totalPages = Math.ceil(totalCount / limit); return { docs: response.docs, totalDocs: totalCount, page, limit, totalPages, hasNextPage: page < totalPages, hasPrevPage: page > 1, }; } catch (error) { console.error("Error finding documents with pagination:", error.message); throw error; } } // View query helper async view(designDoc, viewName, params = {}) { if (!this.isConnected) await this.initialize(); try { const response = await this.db.view(designDoc, viewName, params); return response.rows.map(row => row.value); } catch (error) { console.error("Error querying view:", error.message); throw error; } } // Batch operation helper async bulkDocs(docs) { if (!this.isConnected) await this.initialize(); try { const response = await this.db.bulk({ docs }); return response; } catch (error) { console.error("Error in bulk operation:", error.message); throw error; } } // Conflict resolution helper async resolveConflict(id, conflictResolver) { if (!this.isConnected) await this.initialize(); try { const doc = await this.db.get(id, { open_revs: "all" }); if (!Array.isArray(doc) || doc.length === 1) { return doc[0] || doc; // No conflict } // Multiple revisions exist - resolve conflict const resolvedDoc = await conflictResolver(doc); return await this.updateDocument(resolvedDoc); } catch (error) { console.error("Error resolving conflict:", error.message); throw error; } } // Migration utilities async migrateDocument(mongoDoc, transformFn) { try { const couchDoc = transformFn(mongoDoc); return await this.createDocument(couchDoc); } catch (error) { console.error("Error migrating document:", error.message); throw error; } } // Validate document structure validateDocument(doc, requiredFields = []) { const errors = []; if (!doc.type) { errors.push("Document must have a 'type' field"); } for (const field of requiredFields) { if (!doc[field]) { errors.push(`Document must have '${field}' field`); } } return errors; } // Generate document ID with prefix generateId(type, originalId) { return `${type}_${originalId}`; } // Extract original ID from prefixed ID extractOriginalId(prefixedId) { const parts = prefixedId.split("_"); return parts.length > 1 ? parts.slice(1).join("_") : prefixedId; } // Graceful shutdown async shutdown() { try { if (this.connection) { // Nano doesn't have an explicit close method, but we can mark as disconnected this.isConnected = false; console.log("CouchDB service shut down gracefully"); } } catch (error) { console.error("Error during shutdown:", error.message); } } // Legacy compatibility methods async create(document) { return await this.createDocument(document); } async getById(id) { return await this.getDocument(id); } async update(id, document) { const existing = await this.getDocument(id); if (!existing) { throw new Error("Document not found for update"); } const updatedDoc = { ...document, _id: id, _rev: existing._rev }; return await this.updateDocument(updatedDoc); } async delete(id) { const doc = await this.getDocument(id); if (!doc) return false; await this.deleteDocument(id, doc._rev); return true; } // User-specific operations async findUserByEmail(email) { return this.findOne({ type: 'user', email }); } async findUserById(userId) { return this.getById(userId); } async updateUserPoints(userId, pointsChange, description, relatedEntity = null) { const user = await this.findUserById(userId); if (!user) throw new Error('User not found'); const newPoints = Math.max(0, user.points + pointsChange); // Update user points const updatedUser = await this.update(userId, { ...user, points: newPoints }); // Create point transaction const transaction = { _id: `transaction_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, type: 'point_transaction', user: { userId: userId, name: user.name }, amount: pointsChange, type: this.getTransactionType(description), description, relatedEntity, balanceAfter: newPoints, createdAt: new Date().toISOString() }; await this.create(transaction); // Check for badge awards await this.checkAndAwardBadges(userId, newPoints); return updatedUser; } getTransactionType(description) { if (description.includes('task')) return 'task_completion'; if (description.includes('street')) return 'street_adoption'; if (description.includes('post')) return 'post_creation'; if (description.includes('event')) return 'event_participation'; if (description.includes('reward')) return 'reward_redemption'; return 'admin_adjustment'; } async checkAndAwardBadges(userId, userPoints) { const user = await this.findUserById(userId); const badges = await this.findByType('badge', { isActive: true }); for (const badge of badges) { // Check if user already has this badge const hasBadge = user.earnedBadges.some(earned => earned.badgeId === badge._id); if (hasBadge) continue; let shouldAward = false; let progress = 0; switch (badge.criteria.type) { case 'points_earned': progress = Math.min(100, (userPoints / badge.criteria.threshold) * 100); shouldAward = userPoints >= badge.criteria.threshold; break; case 'street_adoptions': progress = Math.min(100, (user.stats.streetsAdopted / badge.criteria.threshold) * 100); shouldAward = user.stats.streetsAdopted >= badge.criteria.threshold; break; case 'task_completions': progress = Math.min(100, (user.stats.tasksCompleted / badge.criteria.threshold) * 100); shouldAward = user.stats.tasksCompleted >= badge.criteria.threshold; break; case 'post_creations': progress = Math.min(100, (user.stats.postsCreated / badge.criteria.threshold) * 100); shouldAward = user.stats.postsCreated >= badge.criteria.threshold; break; case 'event_participations': progress = Math.min(100, (user.stats.eventsParticipated / badge.criteria.threshold) * 100); shouldAward = user.stats.eventsParticipated >= badge.criteria.threshold; break; } if (shouldAward) { await this.awardBadgeToUser(userId, badge); } else if (progress > 0) { await this.updateBadgeProgress(userId, badge._id, progress); } } } async awardBadgeToUser(userId, badge) { const user = await this.findUserById(userId); const newBadge = { badgeId: badge._id, name: badge.name, description: badge.description, icon: badge.icon, rarity: badge.rarity, earnedAt: new Date().toISOString(), progress: 100 }; user.earnedBadges.push(newBadge); user.stats.badgesEarned = user.earnedBadges.length; await this.update(userId, user); // Create user badge document for tracking const userBadge = { _id: `userbadge_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, type: 'user_badge', userId: userId, badgeId: badge._id, earnedAt: newBadge.earnedAt, progress: 100, createdAt: newBadge.earnedAt, updatedAt: newBadge.earnedAt }; await this.create(userBadge); } async updateBadgeProgress(userId, badgeId, progress) { const existingBadge = await this.findOne({ type: 'user_badge', userId: userId, badgeId: badgeId }); if (existingBadge) { await this.update(existingBadge._id, { ...existingBadge, progress: progress, updatedAt: new Date().toISOString() }); } else { const userBadge = { _id: `userbadge_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, type: 'user_badge', userId: userId, badgeId: badgeId, progress: progress, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }; await this.create(userBadge); } } // Street-specific operations async findStreetsByLocation(bounds) { try { // For CouchDB, we need to handle geospatial queries differently // We'll use a bounding box approach with the geo index const [sw, ne] = bounds; const query = { selector: { type: 'street', status: 'available', location: { $exists: true } }, limit: 1000 // Reasonable limit for geographic queries }; const streets = await this.find(query); // Filter by bounding box manually (since CouchDB doesn't support $geoWithin in Mango) return streets.filter(street => { if (!street.location || !street.location.coordinates) { return false; } const [lng, lat] = street.location.coordinates; return lng >= sw[0] && lng <= ne[0] && lat >= sw[1] && lat <= ne[1]; }); } catch (error) { console.error("Error finding streets by location:", error.message); throw error; } } async adoptStreet(userId, streetId) { const user = await this.findUserById(userId); const street = await this.getById(streetId); if (!user || !street) throw new Error('User or street not found'); if (street.status !== 'available') throw new Error('Street is not available'); if (street.adoptedBy) throw new Error('Street already adopted'); // Update street const updatedStreet = await this.update(streetId, { ...street, adoptedBy: { userId: userId, name: user.name, profilePicture: user.profilePicture || '' }, status: 'adopted' }); // Update user user.adoptedStreets.push(streetId); user.stats.streetsAdopted = user.adoptedStreets.length; await this.update(userId, user); // Award points for street adoption await this.updateUserPoints(userId, 50, 'Street adoption', { entityType: 'Street', entityId: streetId, entityName: street.name }); return updatedStreet; } // Task-specific operations async completeTask(userId, taskId) { const user = await this.findUserById(userId); const task = await this.getById(taskId); if (!user || !task) throw new Error('User or task not found'); if (task.status === 'completed') throw new Error('Task already completed'); // Update task const updatedTask = await this.update(taskId, { ...task, completedBy: { userId: userId, name: user.name, profilePicture: user.profilePicture || '' }, status: 'completed', completedAt: new Date().toISOString() }); // Update user user.completedTasks.push(taskId); user.stats.tasksCompleted = user.completedTasks.length; await this.update(userId, user); // Update street stats if (task.street && task.street.streetId) { const street = await this.getById(task.street.streetId); if (street) { street.stats.completedTasksCount = (street.stats.completedTasksCount || 0) + 1; await this.update(task.street.streetId, street); } } // Award points for task completion await this.updateUserPoints(userId, task.pointsAwarded || 10, `Completed task: ${task.description}`, { entityType: 'Task', entityId: taskId, entityName: task.description }); return updatedTask; } // Post-specific operations async createPost(userId, postData) { const user = await this.findUserById(userId); if (!user) throw new Error('User not found'); const post = { _id: `post_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, type: 'post', user: { userId: userId, name: user.name, profilePicture: user.profilePicture || '' }, content: postData.content, imageUrl: postData.imageUrl, cloudinaryPublicId: postData.cloudinaryPublicId, likes: [], likesCount: 0, commentsCount: 0, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }; const createdPost = await this.create(post); // Update user user.posts.push(createdPost._id); user.stats.postsCreated = user.posts.length; await this.update(userId, user); // Award points for post creation await this.updateUserPoints(userId, 5, `Created post: ${postData.content.substring(0, 50)}...`, { entityType: 'Post', entityId: createdPost._id, entityName: postData.content.substring(0, 50) }); return createdPost; } async togglePostLike(userId, postId) { const post = await this.getById(postId); if (!post) throw new Error('Post not found'); const userLikedIndex = post.likes.indexOf(userId); if (userLikedIndex > -1) { // Unlike post.likes.splice(userLikedIndex, 1); post.likesCount = Math.max(0, post.likesCount - 1); } else { // Like post.likes.push(userId); post.likesCount += 1; } post.updatedAt = new Date().toISOString(); return await this.update(postId, post); } async addCommentToPost(userId, postId, commentContent) { const user = await this.findUserById(userId); const post = await this.getById(postId); if (!user || !post) throw new Error('User or post not found'); const comment = { _id: `comment_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, type: 'comment', post: { postId: postId, content: post.content, userId: post.user.userId }, user: { userId: userId, name: user.name, profilePicture: user.profilePicture || '' }, content: commentContent, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }; const createdComment = await this.create(comment); // Update post comment count post.commentsCount += 1; post.updatedAt = new Date().toISOString(); await this.update(postId, post); return createdComment; } // Event-specific operations async joinEvent(userId, eventId) { const user = await this.findUserById(userId); const event = await this.getById(eventId); if (!user || !event) throw new Error('User or event not found'); if (event.status !== 'upcoming') throw new Error('Event is not upcoming'); // Check if already participating const alreadyParticipating = event.participants.some(p => p.userId === userId); if (alreadyParticipating) throw new Error('User already participating in event'); // Add participant const newParticipant = { userId: userId, name: user.name, profilePicture: user.profilePicture || '', joinedAt: new Date().toISOString() }; event.participants.push(newParticipant); event.participantsCount = event.participants.length; event.updatedAt = new Date().toISOString(); const updatedEvent = await this.update(eventId, event); // Update user user.events.push(eventId); user.stats.eventsParticipated = user.events.length; await this.update(userId, user); // Award points for event participation await this.updateUserPoints(userId, 15, `Joined event: ${event.title}`, { entityType: 'Event', entityId: eventId, entityName: event.title }); return updatedEvent; } // Leaderboard operations async getLeaderboard(limit = 10) { return this.find({ type: 'user', points: { $gt: 0 } }, { sort: [{ points: 'desc' }], limit, fields: ['_id', 'name', 'points', 'profilePicture', 'stats'] }); } // Social feed operations async getSocialFeed(limit = 20, skip = 0) { return this.find({ type: 'post' }, { sort: [{ createdAt: 'desc' }], limit, skip }); } async getPostComments(postId, limit = 50) { return this.find({ type: 'comment', 'post.postId': postId }, { sort: [{ createdAt: 'asc' }], limit }); } // User activity async getUserActivity(userId, limit = 50) { const posts = await this.find({ type: 'post', 'user.userId': userId }, { limit }); const tasks = await this.find({ type: 'task', 'completedBy.userId': userId }, { limit }); const events = await this.find({ type: 'event', 'participants': { $elemMatch: { userId: userId } } }, { limit }); // Combine and sort by date const activity = [ ...posts.map(p => ({ ...p, activityType: 'post' })), ...tasks.map(t => ({ ...t, activityType: 'task' })), ...events.map(e => ({ ...e, activityType: 'event' })) ]; return activity.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)).slice(0, limit); } // Report operations async createReport(userId, streetId, reportData) { const user = await this.findUserById(userId); const street = await this.getById(streetId); if (!user || !street) throw new Error('User or street not found'); const report = { _id: `report_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, type: 'report', street: { streetId: streetId, name: street.name, location: street.location }, user: { userId: userId, name: user.name, profilePicture: user.profilePicture || '' }, issue: reportData.issue, imageUrl: reportData.imageUrl, cloudinaryPublicId: reportData.cloudinaryPublicId, status: 'open', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }; const createdReport = await this.create(report); // Update street stats street.stats.reportsCount = (street.stats.reportsCount || 0) + 1; street.stats.openReportsCount = (street.stats.openReportsCount || 0) + 1; await this.update(streetId, street); return createdReport; } async resolveReport(reportId) { const report = await this.getById(reportId); if (!report) throw new Error('Report not found'); if (report.status === 'resolved') throw new Error('Report already resolved'); const updatedReport = await this.update(reportId, { ...report, status: 'resolved', updatedAt: new Date().toISOString() }); // Update street stats if (report.street && report.street.streetId) { const street = await this.getById(report.street.streetId); if (street) { street.stats.openReportsCount = Math.max(0, (street.stats.openReportsCount || 0) - 1); await this.update(report.street.streetId, street); } } return updatedReport; } } module.exports = new CouchDBService();