const Nano = require('nano'); class CouchDBService { constructor() { this.nano = Nano(process.env.COUCHDB_URL || 'http://localhost:5984'); this.dbName = process.env.COUCHDB_DB || 'adopt-a-street'; this.db = null; } async initialize() { try { this.db = this.nano.db.use(this.dbName); } catch (error) { if (error.statusCode === 404) { await this.nano.db.create(this.dbName); this.db = this.nano.db.use(this.dbName); } else { throw error; } } } // Generic CRUD operations async create(document) { if (!this.db) await this.initialize(); try { const result = await this.db.insert(document); return { ...document, _id: result.id, _rev: result.rev }; } catch (error) { throw new Error(`Failed to create document: ${error.message}`); } } async getById(id) { if (!this.db) await this.initialize(); try { return await this.db.get(id); } catch (error) { if (error.statusCode === 404) return null; throw new Error(`Failed to get document: ${error.message}`); } } async update(id, document) { if (!this.db) await this.initialize(); try { const existing = await this.db.get(id); const updatedDoc = { ...document, _id: id, _rev: existing._rev }; const result = await this.db.insert(updatedDoc); return { ...updatedDoc, _rev: result.rev }; } catch (error) { throw new Error(`Failed to update document: ${error.message}`); } } async delete(id) { if (!this.db) await this.initialize(); try { const doc = await this.db.get(id); await this.db.destroy(id, doc._rev); return true; } catch (error) { if (error.statusCode === 404) return false; throw new Error(`Failed to delete document: ${error.message}`); } } // Query operations async find(selector, options = {}) { if (!this.db) await this.initialize(); try { const query = { selector, ...options }; const result = await this.db.find(query); return result.docs; } catch (error) { throw new Error(`Failed to find documents: ${error.message}`); } } async findOne(selector) { const docs = await this.find(selector, { limit: 1 }); return docs.length > 0 ? docs[0] : null; } async findByType(type, selector = {}, options = {}) { return this.find({ type, ...selector }, options); } // 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) { return this.find({ type: 'street', status: 'available', location: { $geoWithin: { $box: bounds } } }); } 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();