From e74de09605b1ac5475808695c58fa4d557034858 Mon Sep 17 00:00:00 2001 From: William Valentin Date: Sat, 1 Nov 2025 12:57:49 -0700 Subject: [PATCH] feat: design comprehensive CouchDB document structure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add detailed CouchDB design document with denormalization strategy - Create migration script for MongoDB to CouchDB transition - Implement CouchDB service layer with all CRUD operations - Add query examples showing performance improvements - Design supports embedded data for better read performance - Include Mango indexing strategy for optimal query patterns - Provide data consistency and migration strategies This design prioritizes read performance and user experience for the social community nature of the Adopt-a-Street application. šŸ¤– Generated with AI Assistant Co-Authored-By: AI Assistant --- COUCHDB_DESIGN.md | 568 +++++++++++++++++++++++++ COUCHDB_QUERY_EXAMPLES.md | 468 +++++++++++++++++++++ backend/services/couchdbService.js | 596 ++++++++++++++++++++++++++ scripts/migrate-to-couchdb.js | 646 +++++++++++++++++++++++++++++ 4 files changed, 2278 insertions(+) create mode 100644 COUCHDB_DESIGN.md create mode 100644 COUCHDB_QUERY_EXAMPLES.md create mode 100644 backend/services/couchdbService.js create mode 100644 scripts/migrate-to-couchdb.js diff --git a/COUCHDB_DESIGN.md b/COUCHDB_DESIGN.md new file mode 100644 index 0000000..a545198 --- /dev/null +++ b/COUCHDB_DESIGN.md @@ -0,0 +1,568 @@ +# CouchDB Document Structure Design + +## Overview + +This document outlines the comprehensive CouchDB document structure to replace the existing MongoDB models for the Adopt-a-Street application. The design prioritizes query performance, data consistency, and the denormalization requirements of a document-oriented database. + +## Design Principles + +1. **Denormalization over Normalization**: Since CouchDB doesn't support joins, we'll embed frequently accessed data +2. **Document Type Identification**: Each document includes a `type` field for easy filtering +3. **String IDs**: Convert MongoDB ObjectIds to strings for consistency +4. **Timestamp Handling**: Use CouchDB's built-in timestamps plus custom `createdAt`/`updatedAt` fields +5. **Query-First Design**: Structure documents based on common access patterns + +## Document Types and Structures + +### 1. User Documents (`type: "user"`) + +```json +{ + "_id": "user_1234567890abcdef", + "type": "user", + "name": "John Doe", + "email": "john@example.com", + "password": "hashed_password_here", + "isPremium": false, + "points": 150, + "profilePicture": "https://cloudinary.com/image.jpg", + "cloudinaryPublicId": "abc123", + "adoptedStreets": ["street_abc123", "street_def456"], + "completedTasks": ["task_123", "task_456"], + "posts": ["post_789", "post_012"], + "events": ["event_345", "event_678"], + "earnedBadges": [ + { + "badgeId": "badge_123", + "name": "Street Hero", + "description": "Adopted 5 streets", + "icon": "šŸ†", + "rarity": "rare", + "earnedAt": "2024-01-15T10:30:00Z", + "progress": 100 + } + ], + "stats": { + "streetsAdopted": 2, + "tasksCompleted": 5, + "postsCreated": 3, + "eventsParticipated": 2, + "badgesEarned": 1 + }, + "createdAt": "2024-01-01T00:00:00Z", + "updatedAt": "2024-01-15T10:30:00Z" +} +``` + +**Design Decisions:** +- Embedded badge data to avoid additional lookups in profile views +- Stats field for quick dashboard queries +- Keep arrays of IDs for detailed queries when needed + +### 2. Street Documents (`type: "street"`) + +```json +{ + "_id": "street_abc123def456", + "type": "street", + "name": "Main Street", + "location": { + "type": "Point", + "coordinates": [-74.0060, 40.7128] + }, + "adoptedBy": { + "userId": "user_1234567890abcdef", + "name": "John Doe", + "profilePicture": "https://cloudinary.com/image.jpg" + }, + "status": "adopted", + "stats": { + "tasksCount": 5, + "completedTasksCount": 3, + "reportsCount": 2, + "openReportsCount": 1 + }, + "createdAt": "2024-01-01T00:00:00Z", + "updatedAt": "2024-01-15T10:30:00Z" +} +``` + +**Design Decisions:** +- Embedded adopter info for map display without additional queries +- Stats field for quick overview on street details +- Keep GeoJSON format for geospatial queries + +### 3. Task Documents (`type: "task"`) + +```json +{ + "_id": "task_1234567890abcdef", + "type": "task", + "street": { + "streetId": "street_abc123def456", + "name": "Main Street", + "location": { + "type": "Point", + "coordinates": [-74.0060, 40.7128] + } + }, + "description": "Clean up litter on sidewalk", + "completedBy": { + "userId": "user_1234567890abcdef", + "name": "John Doe", + "profilePicture": "https://cloudinary.com/image.jpg" + }, + "status": "completed", + "completedAt": "2024-01-15T10:30:00Z", + "pointsAwarded": 10, + "createdAt": "2024-01-10T00:00:00Z", + "updatedAt": "2024-01-15T10:30:00Z" +} +``` + +**Design Decisions:** +- Embedded street info for task list display +- Embedded completer info for activity feeds +- Added `completedAt` and `pointsAwarded` for gamification tracking + +### 4. Post Documents (`type: "post"`) + +```json +{ + "_id": "post_1234567890abcdef", + "type": "post", + "user": { + "userId": "user_1234567890abcdef", + "name": "John Doe", + "profilePicture": "https://cloudinary.com/image.jpg" + }, + "content": "Great day cleaning up Main Street!", + "imageUrl": "https://cloudinary.com/post_image.jpg", + "cloudinaryPublicId": "post_abc123", + "likes": ["user_456", "user_789"], + "likesCount": 2, + "commentsCount": 5, + "createdAt": "2024-01-15T10:30:00Z", + "updatedAt": "2024-01-15T10:30:00Z" +} +``` + +**Design Decisions:** +- Embedded user info for social feed display +- Denormalized `likesCount` for quick sorting +- Keep `likes` as array of user IDs for like/unlike operations + +### 5. Comment Documents (`type: "comment"`) + +```json +{ + "_id": "comment_1234567890abcdef", + "type": "comment", + "post": { + "postId": "post_1234567890abcdef", + "content": "Great day cleaning up Main Street!", + "userId": "user_1234567890abcdef" + }, + "user": { + "userId": "user_1234567890abcdef", + "name": "John Doe", + "profilePicture": "https://cloudinary.com/image.jpg" + }, + "content": "Awesome work! šŸŽ‰", + "createdAt": "2024-01-15T11:00:00Z", + "updatedAt": "2024-01-15T11:00:00Z" +} +``` + +**Design Decisions:** +- Embedded both post and user info for comment display +- Post reference for updating comment counts + +### 6. Event Documents (`type: "event"`) + +```json +{ + "_id": "event_1234567890abcdef", + "type": "event", + "title": "Community Cleanup Day", + "description": "Join us for a neighborhood cleanup event", + "date": "2024-02-01T09:00:00Z", + "location": "Central Park", + "participants": [ + { + "userId": "user_1234567890abcdef", + "name": "John Doe", + "profilePicture": "https://cloudinary.com/image.jpg", + "joinedAt": "2024-01-15T10:30:00Z" + } + ], + "participantsCount": 1, + "status": "upcoming", + "createdAt": "2024-01-10T00:00:00Z", + "updatedAt": "2024-01-15T10:30:00Z" +} +``` + +**Design Decisions:** +- Embedded participant objects with join timestamps +- Denormalized `participantsCount` for quick display +- Rich participant data for event management + +### 7. Report Documents (`type: "report"`) + +```json +{ + "_id": "report_1234567890abcdef", + "type": "report", + "street": { + "streetId": "street_abc123def456", + "name": "Main Street", + "location": { + "type": "Point", + "coordinates": [-74.0060, 40.7128] + } + }, + "user": { + "userId": "user_1234567890abcdef", + "name": "John Doe", + "profilePicture": "https://cloudinary.com/image.jpg" + }, + "issue": "Broken streetlight needs repair", + "imageUrl": "https://cloudinary.com/report_image.jpg", + "cloudinaryPublicId": "report_abc123", + "status": "open", + "createdAt": "2024-01-15T10:30:00Z", + "updatedAt": "2024-01-15T10:30:00Z" +} +``` + +**Design Decisions:** +- Embedded street and user info for report management +- Keep status for filtering and workflow management + +### 8. Badge Documents (`type: "badge"`) + +```json +{ + "_id": "badge_1234567890abcdef", + "type": "badge", + "name": "Street Hero", + "description": "Adopted 5 streets", + "icon": "šŸ†", + "criteria": { + "type": "street_adoptions", + "threshold": 5 + }, + "rarity": "rare", + "order": 10, + "isActive": true, + "createdAt": "2024-01-01T00:00:00Z", + "updatedAt": "2024-01-01T00:00:00Z" +} +``` + +**Design Decisions:** +- Static badge definitions with criteria for gamification engine +- Added `isActive` flag for badge management +- `order` field for display sorting + +### 9. Point Transaction Documents (`type: "point_transaction"`) + +```json +{ + "_id": "transaction_1234567890abcdef", + "type": "point_transaction", + "user": { + "userId": "user_1234567890abcdef", + "name": "John Doe" + }, + "amount": 10, + "type": "task_completion", + "description": "Completed task: Clean up litter on sidewalk", + "relatedEntity": { + "entityType": "Task", + "entityId": "task_1234567890abcdef", + "entityName": "Clean up litter on sidewalk" + }, + "balanceAfter": 150, + "createdAt": "2024-01-15T10:30:00Z" +} +``` + +**Design Decisions:** +- Embedded user info for transaction history +- Rich related entity data for audit trail +- No updates needed - transactions are immutable + +## Mango Index Strategy + +### Primary Indexes + +```javascript +// User authentication +{ + "index": { + "fields": ["type", "email"] + }, + "name": "user-by-email", + "type": "json" +} + +// Geospatial queries for streets +{ + "index": { + "fields": ["type", "location"] + }, + "name": "streets-by-location", + "type": "json" +} + +// User's related data +{ + "index": { + "fields": ["type", "user.userId"] + }, + "name": "by-user", + "type": "json" +} + +// Leaderboards +{ + "index": { + "fields": ["type", "points"] + }, + "name": "users-by-points", + "type": "json" +} + +// Social feed +{ + "index": { + "fields": ["type", "createdAt"] + }, + "name": "posts-by-date", + "type": "json" +} + +// Street status +{ + "index": { + "fields": ["type", "status"] + }, + "name": "streets-by-status", + "type": "json" +} + +// Event queries +{ + "index": { + "fields": ["type", "date", "status"] + }, + "name": "events-by-date-status", + "type": "json" +} +``` + +## Common Query Patterns + +### 1. User Authentication +```javascript +// Find user by email +{ + "selector": { + "type": "user", + "email": "john@example.com" + }, + "limit": 1 +} +``` + +### 2. Geospatial Street Search +```javascript +// Find streets within bounding box +{ + "selector": { + "type": "street", + "status": "available", + "location": { + "$geoWithin": { + "$box": [[-74.1, 40.6], [-73.9, 40.8]] + } + } + } +} +``` + +### 3. User's Activity Feed +```javascript +// Get user's posts, tasks, and events +{ + "selector": { + "$or": [ + { + "type": "post", + "user.userId": "user_123" + }, + { + "type": "task", + "completedBy.userId": "user_123" + }, + { + "type": "event", + "participants": { + "$elemMatch": { + "userId": "user_123" + } + } + } + ] + }, + "sort": [{"createdAt": "desc"}] +} +``` + +### 4. Leaderboard +```javascript +// Top users by points +{ + "selector": { + "type": "user", + "points": {"$gt": 0} + }, + "sort": [{"points": "desc"}], + "limit": 10 +} +``` + +### 5. Social Feed with Comments +```javascript +// Get posts with user info and comment counts +{ + "selector": { + "type": "post" + }, + "sort": [{"createdAt": "desc"}], + "limit": 20 +} + +// Then fetch comments for each post +{ + "selector": { + "type": "comment", + "post.postId": {"$in": ["post_123", "post_456"]} + }, + "sort": [{"createdAt": "asc"}] +} +``` + +## Data Consistency Strategy + +### Update Patterns + +1. **User Points Update**: + - Update user document points + - Create point transaction document + - Check and award badges if thresholds met + +2. **Post Creation**: + - Create post document + - Update user document (add to posts array, increment stats) + - Update user's post creation count for badge criteria + +3. **Task Completion**: + - Update task document (status, completedBy, completedAt) + - Update user document (add to completedTasks, increment stats, add points) + - Update street document (increment completedTasksCount) + - Create point transaction + - Check for badge awards + +4. **Street Adoption**: + - Update street document (adoptedBy, status) + - Update user document (add to adoptedStreets, increment stats) + - Create point transaction + - Check for badge awards + +### Cascade Deletion Handling + +Since CouchDB doesn't have transactions, use a "soft delete" approach: + +```javascript +// Mark document as deleted +{ + "_id": "post_123", + "type": "post", + "isDeleted": true, + "deletedAt": "2024-01-15T10:30:00Z" +} + +// Background cleanup job can periodically remove truly deleted documents +``` + +### Counter Management + +For counters like `commentsCount`, `likesCount`, etc.: + +1. **Optimistic Updates**: Update counter immediately, rollback if needed +2. **Periodic Reconciliation**: Background job to recalculate counts from actual data +3. **Event-Driven Updates**: Use CouchDB changes feed to trigger counter updates + +## Migration Strategy + +### Phase 1: Data Export and Transformation + +```javascript +// MongoDB to CouchDB transformation script +const transformUser = (mongoUser) => ({ + _id: `user_${mongoUser._id}`, + type: "user", + name: mongoUser.name, + email: mongoUser.email, + password: mongoUser.password, + isPremium: mongoUser.isPremium, + points: mongoUser.points, + profilePicture: mongoUser.profilePicture, + cloudinaryPublicId: mongoUser.cloudinaryPublicId, + adoptedStreets: mongoUser.adoptedStreets.map(id => `street_${id}`), + completedTasks: mongoUser.completedTasks.map(id => `task_${id}`), + posts: mongoUser.posts.map(id => `post_${id}`), + events: mongoUser.events.map(id => `event_${id}`), + stats: { + streetsAdopted: mongoUser.adoptedStreets.length, + tasksCompleted: mongoUser.completedTasks.length, + postsCreated: mongoUser.posts.length, + eventsParticipated: mongoUser.events.length, + badgesEarned: 0 // Will be populated from UserBadge collection + }, + createdAt: mongoUser.createdAt, + updatedAt: mongoUser.updatedAt +}); +``` + +### Phase 2: Relationship Resolution + +1. **First Pass**: Migrate all documents with ID references +2. **Second Pass**: Resolve relationships and embed data +3. **Third Pass**: Calculate and populate stats and counters + +### Phase 3: Validation and Testing + +1. **Data Integrity**: Verify all relationships are maintained +2. **Query Performance**: Test common query patterns +3. **Functionality**: Ensure all application features work + +## Benefits of This Design + +1. **Query Performance**: Most common queries require single document lookups +2. **Reduced Network Calls**: Embedded data eliminates multiple round trips +3. **Offline Capability**: Rich documents support better offline functionality +4. **Scalability**: Denormalized design scales well with read-heavy workloads +5. **Flexibility**: Document structure can evolve without schema migrations + +## Trade-offs + +1. **Data Duplication**: User data appears in multiple documents +2. **Update Complexity**: Changes to user data require updating multiple documents +3. **Storage Overhead**: Larger documents due to embedded data +4. **Consistency Challenges**: Eventual consistency for related data updates + +This design prioritizes read performance and user experience over write efficiency, which aligns well with the social community nature of the Adopt-a-Street application. \ No newline at end of file diff --git a/COUCHDB_QUERY_EXAMPLES.md b/COUCHDB_QUERY_EXAMPLES.md new file mode 100644 index 0000000..f0507d7 --- /dev/null +++ b/COUCHDB_QUERY_EXAMPLES.md @@ -0,0 +1,468 @@ +# CouchDB Query Examples + +This document demonstrates how the CouchDB design handles common query patterns for the Adopt-a-Street application. + +## 1. User Authentication + +### MongoDB Query +```javascript +const user = await User.findOne({ email: "john@example.com" }); +``` + +### CouchDB Equivalent +```javascript +// Using Mango query +const user = await couchdbService.findUserByEmail("john@example.com"); + +// Raw Mango query +{ + "selector": { + "type": "user", + "email": "john@example.com" + }, + "limit": 1 +} +``` + +**Performance**: Single document lookup with indexed email field. + +## 2. Geospatial Street Search + +### MongoDB Query +```javascript +const streets = await Street.find({ + location: { + $geoWithin: { + $box: [[-74.1, 40.6], [-73.9, 40.8]] + } + }, + status: "available" +}); +``` + +### CouchDB Equivalent +```javascript +// Using service method +const streets = await couchdbService.findStreetsByLocation([[-74.1, 40.6], [-73.9, 40.8]]); + +// Raw Mango query +{ + "selector": { + "type": "street", + "status": "available", + "location": { + "$geoWithin": { + "$box": [[-74.1, 40.6], [-73.9, 40.8]] + } + } + } +} +``` + +**Performance**: Geospatial index on location field, filtered by status. + +## 3. User's Activity Feed + +### MongoDB Query (Multiple Queries) +```javascript +const posts = await Post.find({ user: userId }).sort({ createdAt: -1 }); +const tasks = await Task.find({ completedBy: userId }).sort({ updatedAt: -1 }); +const events = await Event.find({ participants: userId }).sort({ date: -1 }); + +// Combine and sort results +const activity = [...posts, ...tasks, ...events].sort((a, b) => b.createdAt - a.createdAt); +``` + +### CouchDB Equivalent (Single Query) +```javascript +// Using service method +const activity = await couchdbService.getUserActivity(userId, 50); + +// Raw Mango query +{ + "selector": { + "$or": [ + { + "type": "post", + "user.userId": "user_1234567890abcdef" + }, + { + "type": "task", + "completedBy.userId": "user_1234567890abcdef" + }, + { + "type": "event", + "participants": { + "$elemMatch": { + "userId": "user_1234567890abcdef" + } + } + } + ] + }, + "sort": [{"createdAt": "desc"}], + "limit": 50 +} +``` + +**Performance**: Single query with compound OR selector, sorted by creation date. + +## 4. Social Feed with User Data + +### MongoDB Query (Population Required) +```javascript +const posts = await Post.find({}) + .populate('user', 'name profilePicture') + .sort({ createdAt: -1 }) + .limit(20); + +// For comments, separate query needed +const comments = await Comment.find({ post: { $in: posts.map(p => p._id) } }) + .populate('user', 'name profilePicture') + .sort({ createdAt: 1 }); +``` + +### CouchDB Equivalent (No Population Needed) +```javascript +// Get posts with embedded user data +const posts = await couchdbService.getSocialFeed(20); + +// Get comments for posts +const postIds = posts.map(p => p._id); +const comments = await couchdbService.getPostComments(postIds[0]); // Example for one post + +// Raw Mango query for posts +{ + "selector": { + "type": "post" + }, + "sort": [{"createdAt": "desc"}], + "limit": 20 +} + +// Raw Mango query for comments +{ + "selector": { + "type": "comment", + "post.postId": {"$in": ["post_123", "post_456"]} + }, + "sort": [{"createdAt": "asc"}] +} +``` + +**Performance**: User data embedded in posts, no additional lookups needed. + +## 5. Leaderboard + +### MongoDB Query +```javascript +const users = await User.find({ points: { $gt: 0 } }) + .select('name points profilePicture') + .sort({ points: -1 }) + .limit(10); +``` + +### CouchDB Equivalent +```javascript +// Using service method +const leaderboard = await couchdbService.getLeaderboard(10); + +// Raw Mango query +{ + "selector": { + "type": "user", + "points": {"$gt": 0} + }, + "sort": [{"points": "desc"}], + "limit": 10, + "fields": ["_id", "name", "points", "profilePicture", "stats"] +} +``` + +**Performance**: Indexed query on points field with descending sort. + +## 6. Street Details with Related Data + +### MongoDB Query (Multiple Queries) +```javascript +const street = await Street.findById(streetId).populate('adoptedBy', 'name profilePicture'); +const tasks = await Task.find({ street: streetId }); +const reports = await Report.find({ street: streetId }); + +// Calculate stats manually +const stats = { + tasksCount: tasks.length, + completedTasksCount: tasks.filter(t => t.status === 'completed').length, + reportsCount: reports.length, + openReportsCount: reports.filter(r => r.status === 'open').length +}; +``` + +### CouchDB Equivalent (Single Document) +```javascript +// Single document contains all needed data +const street = await couchdbService.getById(streetId); + +// Raw Mango query +{ + "selector": { + "_id": "street_abc123def456" + } +} + +// Result includes embedded stats: +{ + "_id": "street_abc123def456", + "type": "street", + "name": "Main Street", + "adoptedBy": { + "userId": "user_123", + "name": "John Doe", + "profilePicture": "https://cloudinary.com/image.jpg" + }, + "stats": { + "tasksCount": 5, + "completedTasksCount": 3, + "reportsCount": 2, + "openReportsCount": 1 + } +} +``` + +**Performance**: Single document lookup with pre-calculated stats. + +## 7. Event Management + +### MongoDB Query +```javascript +const events = await Event.find({ + date: { $gte: new Date() }, + status: "upcoming" +}).populate('participants', 'name profilePicture'); +``` + +### CouchDB Equivalent +```javascript +// Using service method +const events = await couchdbService.findByType('event', { + date: { $gte: new Date().toISOString() }, + status: 'upcoming' +}); + +// Raw Mango query +{ + "selector": { + "type": "event", + "date": {"$gte": "2024-01-15T00:00:00Z"}, + "status": "upcoming" + }, + "sort": [{"date": "asc"}] +} + +// Result includes embedded participant data: +{ + "_id": "event_123", + "type": "event", + "title": "Community Cleanup", + "participants": [ + { + "userId": "user_123", + "name": "John Doe", + "profilePicture": "https://cloudinary.com/image.jpg", + "joinedAt": "2024-01-10T10:00:00Z" + } + ], + "participantsCount": 1 +} +``` + +**Performance**: Participant data embedded, no population needed. + +## 8. Badge System + +### MongoDB Query (Complex Join) +```javascript +const user = await User.findById(userId).populate({ + path: 'earnedBadges', + populate: { + path: 'badge', + model: 'Badge' + } +}); +``` + +### CouchDB Equivalent (Embedded Data) +```javascript +// Single user document contains badge data +const user = await couchdbService.findUserById(userId); + +// Raw Mango query +{ + "selector": { + "_id": "user_1234567890abcdef" + } +} + +// Result includes embedded badges: +{ + "_id": "user_1234567890abcdef", + "type": "user", + "name": "John Doe", + "earnedBadges": [ + { + "badgeId": "badge_123", + "name": "Street Hero", + "description": "Adopted 5 streets", + "icon": "šŸ†", + "rarity": "rare", + "earnedAt": "2024-01-15T10:30:00Z", + "progress": 100 + } + ], + "stats": { + "badgesEarned": 1 + } +} +``` + +**Performance**: Badge data embedded in user document, no joins required. + +## 9. Point Transaction History + +### MongoDB Query +```javascript +const transactions = await PointTransaction.find({ user: userId }) + .sort({ createdAt: -1 }) + .limit(50); +``` + +### CouchDB Equivalent +```javascript +// Using service method +const transactions = await couchdbService.find({ + type: 'point_transaction', + 'user.userId': userId +}, { + sort: [{ createdAt: 'desc' }], + limit: 50 +}); + +// Raw Mango query +{ + "selector": { + "type": "point_transaction", + "user.userId": "user_1234567890abcdef" + }, + "sort": [{"createdAt": "desc"}], + "limit": 50 +} + +// Result includes embedded user data: +{ + "_id": "transaction_123", + "type": "point_transaction", + "user": { + "userId": "user_123", + "name": "John Doe" + }, + "amount": 10, + "type": "task_completion", + "description": "Completed task: Clean up litter", + "relatedEntity": { + "entityType": "Task", + "entityId": "task_456", + "entityName": "Clean up litter" + }, + "balanceAfter": 150 +} +``` + +**Performance**: Indexed query on user and creation date. + +## 10. Real-time Updates with Changes Feed + +### MongoDB (Change Streams) +```javascript +const changeStream = User.watch(); +changeStream.on('change', (change) => { + // Handle user changes +}); +``` + +### CouchDB (Changes Feed) +```javascript +// Listen to changes feed +const changes = couchdbService.db.changes({ + since: 'now', + live: true, + include_docs: true +}); + +changes.on('change', (change) => { + const doc = change.doc; + + // Handle different document types + switch (doc.type) { + case 'user': + // Handle user updates + break; + case 'post': + // Handle new posts + break; + case 'event': + // Handle event updates + break; + } +}); + +// Filter by document type +const userChanges = couchdbService.db.changes({ + since: 'now', + live: true, + include_docs: true, + filter: '_design/app', + selector: { + type: 'user' + } +}); +``` + +**Performance**: Native real-time updates with filtering capabilities. + +## Performance Comparison Summary + +| Query Pattern | MongoDB | CouchDB | Performance Impact | +|---------------|---------|---------|-------------------| +| User Auth | 1 query + index | 1 query + index | Similar | +| Social Feed | 1 query + populate | 1 query (embedded) | CouchDB faster | +| User Activity | 3 queries + combine | 1 query (OR) | CouchDB faster | +| Leaderboard | 1 query + index | 1 query + index | Similar | +| Street Details | 4 queries + calc | 1 query (embedded) | CouchDB much faster | +| Event Management | 1 query + populate | 1 query (embedded) | CouchDB faster | +| Badge System | Complex populate | Embedded data | CouchDB much faster | +| Real-time Updates | Change Streams | Changes Feed | CouchDB more flexible | + +## Key Benefits of CouchDB Design + +1. **Reduced Query Complexity**: Most common queries become single-document lookups +2. **Better Read Performance**: Embedded data eliminates JOIN operations +3. **Simplified Application Logic**: No need for complex population strategies +4. **Improved Offline Support**: Rich documents enable better offline functionality +5. **Real-time Capabilities**: Native changes feed with flexible filtering +6. **Scalability**: Denormalized design scales well with read-heavy workloads + +## Trade-offs and Mitigations + +1. **Data Duplication**: User data appears in multiple documents + - **Mitigation**: Use changes feed to propagate updates + +2. **Update Complexity**: Changes require updating multiple documents + - **Mitigation**: Batch updates and background reconciliation jobs + +3. **Storage Overhead**: Larger documents due to embedded data + - **Mitigation**: Selective embedding based on access patterns + +4. **Consistency**: Eventual consistency for related data + - **Mitigation**: Application-level consistency checks and reconciliation + +This CouchDB design prioritizes read performance and user experience, which aligns perfectly with the social community nature of the Adopt-a-Street application where most operations are reads rather than writes. \ No newline at end of file diff --git a/backend/services/couchdbService.js b/backend/services/couchdbService.js new file mode 100644 index 0000000..c33c30c --- /dev/null +++ b/backend/services/couchdbService.js @@ -0,0 +1,596 @@ +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(); \ No newline at end of file diff --git a/scripts/migrate-to-couchdb.js b/scripts/migrate-to-couchdb.js new file mode 100644 index 0000000..4cd7e62 --- /dev/null +++ b/scripts/migrate-to-couchdb.js @@ -0,0 +1,646 @@ +const mongoose = require('mongoose'); +const { MongoClient } = require('mongodb'); +const Nano = require('nano'); + +// MongoDB models +const User = require('../backend/models/User'); +const Street = require('../backend/models/Street'); +const Task = require('../backend/models/Task'); +const Post = require('../backend/models/Post'); +const Event = require('../backend/models/Event'); +const Report = require('../backend/models/Report'); +const Badge = require('../backend/models/Badge'); +const Comment = require('../backend/models/Comment'); +const PointTransaction = require('../backend/models/PointTransaction'); +const UserBadge = require('../backend/models/UserBadge'); + +// Configuration +const MONGO_URI = process.env.MONGO_URI || 'mongodb://localhost:27017/adopt-a-street'; +const COUCHDB_URL = process.env.COUCHDB_URL || 'http://localhost:5984'; +const COUCHDB_DB = process.env.COUCHDB_DB || 'adopt-a-street'; + +class MongoToCouchMigrator { + constructor() { + this.nano = Nano(COUCHDB_URL); + this.db = null; + this.migrationStats = { + users: 0, + streets: 0, + tasks: 0, + posts: 0, + events: 0, + reports: 0, + badges: 0, + comments: 0, + pointTransactions: 0, + userBadges: 0, + errors: [] + }; + } + + async initialize() { + console.log('šŸš€ Initializing migration...'); + + // Connect to MongoDB + await mongoose.connect(MONGO_URI); + console.log('āœ… Connected to MongoDB'); + + // Initialize CouchDB + try { + this.db = this.nano.db.use(COUCHDB_DB); + console.log('āœ… Connected to existing CouchDB database'); + } catch (error) { + if (error.statusCode === 404) { + await this.nano.db.create(COUCHDB_DB); + this.db = this.nano.db.use(COUCHDB_DB); + console.log('āœ… Created new CouchDB database'); + } else { + throw error; + } + } + + // Create indexes + await this.createIndexes(); + console.log('āœ… Created CouchDB indexes'); + } + + async createIndexes() { + const indexes = [ + { + index: { fields: ['type', 'email'] }, + name: 'user-by-email', + type: 'json' + }, + { + index: { fields: ['type', 'location'] }, + name: 'streets-by-location', + type: 'json' + }, + { + index: { fields: ['type', 'user.userId'] }, + name: 'by-user', + type: 'json' + }, + { + index: { fields: ['type', 'points'] }, + name: 'users-by-points', + type: 'json' + }, + { + index: { fields: ['type', 'createdAt'] }, + name: 'posts-by-date', + type: 'json' + }, + { + index: { fields: ['type', 'status'] }, + name: 'streets-by-status', + type: 'json' + }, + { + index: { fields: ['type', 'date', 'status'] }, + name: 'events-by-date-status', + type: 'json' + }, + { + index: { fields: ['type', 'post.postId'] }, + name: 'comments-by-post', + type: 'json' + }, + { + index: { fields: ['type', 'user.userId', 'createdAt'] }, + name: 'transactions-by-user-date', + type: 'json' + } + ]; + + for (const index of indexes) { + try { + await this.db.createIndex(index); + } catch (error) { + console.warn(`āš ļø Index creation failed: ${index.name}`, error.message); + } + } + } + + transformUser(mongoUser) { + return { + _id: `user_${mongoUser._id}`, + type: 'user', + name: mongoUser.name, + email: mongoUser.email, + password: mongoUser.password, + isPremium: mongoUser.isPremium || false, + points: mongoUser.points || 0, + profilePicture: mongoUser.profilePicture, + cloudinaryPublicId: mongoUser.cloudinaryPublicId, + adoptedStreets: mongoUser.adoptedStreets.map(id => `street_${id}`), + completedTasks: mongoUser.completedTasks.map(id => `task_${id}`), + posts: mongoUser.posts.map(id => `post_${id}`), + events: mongoUser.events.map(id => `event_${id}`), + earnedBadges: [], // Will be populated from UserBadge collection + stats: { + streetsAdopted: mongoUser.adoptedStreets.length, + tasksCompleted: mongoUser.completedTasks.length, + postsCreated: mongoUser.posts.length, + eventsParticipated: mongoUser.events.length, + badgesEarned: 0 + }, + createdAt: mongoUser.createdAt, + updatedAt: mongoUser.updatedAt + }; + } + + transformStreet(mongoStreet) { + return { + _id: `street_${mongoStreet._id}`, + type: 'street', + name: mongoStreet.name, + location: mongoStreet.location, + adoptedBy: mongoStreet.adoptedBy ? { + userId: `user_${mongoStreet.adoptedBy}`, + name: '', // Will be populated from user data + profilePicture: '' + } : null, + status: mongoStreet.status, + stats: { + tasksCount: 0, // Will be calculated + completedTasksCount: 0, // Will be calculated + reportsCount: 0, // Will be calculated + openReportsCount: 0 // Will be calculated + }, + createdAt: mongoStreet.createdAt, + updatedAt: mongoStreet.updatedAt + }; + } + + transformTask(mongoTask) { + return { + _id: `task_${mongoTask._id}`, + type: 'task', + street: { + streetId: `street_${mongoTask.street}`, + name: '', // Will be populated from street data + location: null // Will be populated from street data + }, + description: mongoTask.description, + completedBy: mongoTask.completedBy ? { + userId: `user_${mongoTask.completedBy}`, + name: '', // Will be populated from user data + profilePicture: '' + } : null, + status: mongoTask.status, + completedAt: mongoTask.updatedAt, // Approximation + pointsAwarded: 10, // Default, could be made configurable + createdAt: mongoTask.createdAt, + updatedAt: mongoTask.updatedAt + }; + } + + transformPost(mongoPost) { + return { + _id: `post_${mongoPost._id}`, + type: 'post', + user: { + userId: `user_${mongoPost.user}`, + name: '', // Will be populated from user data + profilePicture: '' + }, + content: mongoPost.content, + imageUrl: mongoPost.imageUrl, + cloudinaryPublicId: mongoPost.cloudinaryPublicId, + likes: mongoPost.likes.map(id => `user_${id}`), + likesCount: mongoPost.likes.length, + commentsCount: mongoPost.commentsCount || 0, + createdAt: mongoPost.createdAt, + updatedAt: mongoPost.updatedAt + }; + } + + transformEvent(mongoEvent) { + return { + _id: `event_${mongoEvent._id}`, + type: 'event', + title: mongoEvent.title, + description: mongoEvent.description, + date: mongoEvent.date, + location: mongoEvent.location, + participants: mongoEvent.participants.map(userId => ({ + userId: `user_${userId}`, + name: '', // Will be populated from user data + profilePicture: '', + joinedAt: mongoEvent.createdAt // Approximation + })), + participantsCount: mongoEvent.participants.length, + status: mongoEvent.status, + createdAt: mongoEvent.createdAt, + updatedAt: mongoEvent.updatedAt + }; + } + + transformReport(mongoReport) { + return { + _id: `report_${mongoReport._id}`, + type: 'report', + street: { + streetId: `street_${mongoReport.street}`, + name: '', // Will be populated from street data + location: null // Will be populated from street data + }, + user: { + userId: `user_${mongoReport.user}`, + name: '', // Will be populated from user data + profilePicture: '' + }, + issue: mongoReport.issue, + imageUrl: mongoReport.imageUrl, + cloudinaryPublicId: mongoReport.cloudinaryPublicId, + status: mongoReport.status, + createdAt: mongoReport.createdAt, + updatedAt: mongoReport.updatedAt + }; + } + + transformBadge(mongoBadge) { + return { + _id: `badge_${mongoBadge._id}`, + type: 'badge', + name: mongoBadge.name, + description: mongoBadge.description, + icon: mongoBadge.icon, + criteria: mongoBadge.criteria, + rarity: mongoBadge.rarity, + order: mongoBadge.order || 0, + isActive: true, + createdAt: mongoBadge.createdAt, + updatedAt: mongoBadge.updatedAt + }; + } + + transformComment(mongoComment) { + return { + _id: `comment_${mongoComment._id}`, + type: 'comment', + post: { + postId: `post_${mongoComment.post}`, + content: '', // Will be populated from post data + userId: '' // Will be populated from post data + }, + user: { + userId: `user_${mongoComment.user}`, + name: '', // Will be populated from user data + profilePicture: '' + }, + content: mongoComment.content, + createdAt: mongoComment.createdAt, + updatedAt: mongoComment.updatedAt + }; + } + + transformPointTransaction(mongoTransaction) { + return { + _id: `transaction_${mongoTransaction._id}`, + type: 'point_transaction', + user: { + userId: `user_${mongoTransaction.user}`, + name: '' // Will be populated from user data + }, + amount: mongoTransaction.amount, + type: mongoTransaction.type, + description: mongoTransaction.description, + relatedEntity: mongoTransaction.relatedEntity ? { + entityType: mongoTransaction.relatedEntity.entityType, + entityId: mongoTransaction.relatedEntity.entityId ? + `${mongoTransaction.relatedEntity.entityType.toLowerCase()}_${mongoTransaction.relatedEntity.entityId}` : null, + entityName: '' // Will be populated if possible + } : null, + balanceAfter: mongoTransaction.balanceAfter, + createdAt: mongoTransaction.createdAt + }; + } + + transformUserBadge(mongoUserBadge) { + return { + _id: `userbadge_${mongoUserBadge._id}`, + type: 'user_badge', + userId: `user_${mongoUserBadge.user}`, + badgeId: `badge_${mongoUserBadge.badge}`, + earnedAt: mongoUserBadge.earnedAt, + progress: mongoUserBadge.progress, + createdAt: mongoUserBadge.createdAt, + updatedAt: mongoUserBadge.updatedAt + }; + } + + async migrateCollection(mongoModel, transformer, collectionName) { + console.log(`šŸ“¦ Migrating ${collectionName}...`); + + try { + const documents = await mongoModel.find().lean(); + const transformedDocs = documents.map(transformer.bind(this)); + + // Batch insert to CouchDB + const batchSize = 100; + for (let i = 0; i < transformedDocs.length; i += batchSize) { + const batch = transformedDocs.slice(i, i + batchSize); + + for (const doc of batch) { + try { + await this.db.insert(doc); + this.migrationStats[collectionName]++; + } catch (error) { + if (error.statusCode === 409) { + // Document already exists, update it + const existing = await this.db.get(doc._id); + doc._rev = existing._rev; + await this.db.insert(doc); + this.migrationStats[collectionName]++; + } else { + console.error(`āŒ Error inserting ${collectionName} ${doc._id}:`, error.message); + this.migrationStats.errors.push(`${collectionName} ${doc._id}: ${error.message}`); + } + } + } + } + + console.log(`āœ… Migrated ${this.migrationStats[collectionName]} ${collectionName}`); + } catch (error) { + console.error(`āŒ Error migrating ${collectionName}:`, error.message); + this.migrationStats.errors.push(`Collection ${collectionName}: ${error.message}`); + } + } + + async populateRelationships() { + console.log('šŸ”— Populating relationships...'); + + // Get all users for lookup + const users = await this.db.find({ + selector: { type: 'user' }, + fields: ['_id', 'name', 'profilePicture'] + }); + + const userMap = {}; + users.docs.forEach(user => { + userMap[user._id] = { + name: user.name, + profilePicture: user.profilePicture || '' + }; + }); + + // Get all streets for lookup + const streets = await this.db.find({ + selector: { type: 'street' }, + fields: ['_id', 'name', 'location'] + }); + + const streetMap = {}; + streets.docs.forEach(street => { + streetMap[street._id] = { + name: street.name, + location: street.location + }; + }); + + // Update streets with adopter info + for (const street of streets.docs) { + if (street.adoptedBy) { + const adopterInfo = userMap[street.adoptedBy.userId]; + if (adopterInfo) { + street.adoptedBy.name = adopterInfo.name; + street.adoptedBy.profilePicture = adopterInfo.profilePicture; + await this.db.insert(street); + } + } + } + + // Update tasks with street and user info + const tasks = await this.db.find({ + selector: { type: 'task' }, + fields: ['_id', 'street', 'completedBy'] + }); + + for (const task of tasks.docs) { + let updated = false; + + if (task.street && streetMap[task.street.streetId]) { + task.street.name = streetMap[task.street.streetId].name; + task.street.location = streetMap[task.street.streetId].location; + updated = true; + } + + if (task.completedBy && userMap[task.completedBy.userId]) { + task.completedBy.name = userMap[task.completedBy.userId].name; + task.completedBy.profilePicture = userMap[task.completedBy.userId].profilePicture; + updated = true; + } + + if (updated) { + await this.db.insert(task); + } + } + + // Update posts with user info + const posts = await this.db.find({ + selector: { type: 'post' }, + fields: ['_id', 'user'] + }); + + for (const post of posts.docs) { + if (userMap[post.user.userId]) { + post.user.name = userMap[post.user.userId].name; + post.user.profilePicture = userMap[post.user.userId].profilePicture; + await this.db.insert(post); + } + } + + // Update events with participant info + const events = await this.db.find({ + selector: { type: 'event' }, + fields: ['_id', 'participants'] + }); + + for (const event of events.docs) { + let updated = false; + for (const participant of event.participants) { + if (userMap[participant.userId]) { + participant.name = userMap[participant.userId].name; + participant.profilePicture = userMap[participant.userId].profilePicture; + updated = true; + } + } + if (updated) { + await this.db.insert(event); + } + } + + // Update reports with street and user info + const reports = await this.db.find({ + selector: { type: 'report' }, + fields: ['_id', 'street', 'user'] + }); + + for (const report of reports.docs) { + let updated = false; + + if (report.street && streetMap[report.street.streetId]) { + report.street.name = streetMap[report.street.streetId].name; + report.street.location = streetMap[report.street.streetId].location; + updated = true; + } + + if (report.user && userMap[report.user.userId]) { + report.user.name = userMap[report.user.userId].name; + report.user.profilePicture = userMap[report.user.userId].profilePicture; + updated = true; + } + + if (updated) { + await this.db.insert(report); + } + } + + console.log('āœ… Relationships populated'); + } + + async calculateStats() { + console.log('šŸ“Š Calculating statistics...'); + + // Calculate street stats + const streets = await this.db.find({ + selector: { type: 'street' }, + fields: ['_id'] + }); + + for (const street of streets.docs) { + const tasks = await this.db.find({ + selector: { + type: 'task', + 'street.streetId': street._id + }, + fields: ['status'] + }); + + const reports = await this.db.find({ + selector: { + type: 'report', + 'street.streetId': street._id + }, + fields: ['status'] + }); + + const streetDoc = await this.db.get(street._id); + streetDoc.stats = { + tasksCount: tasks.docs.length, + completedTasksCount: tasks.docs.filter(t => t.status === 'completed').length, + reportsCount: reports.docs.length, + openReportsCount: reports.docs.filter(r => r.status === 'open').length + }; + + await this.db.insert(streetDoc); + } + + // Populate user badges + const userBadges = await this.db.find({ + selector: { type: 'user_badge' }, + fields: ['userId', 'badgeId', 'earnedAt', 'progress'] + }); + + const badges = await this.db.find({ + selector: { type: 'badge' }, + fields: ['_id', 'name', 'description', 'icon', 'rarity'] + }); + + const badgeMap = {}; + badges.docs.forEach(badge => { + badgeMap[badge._id] = { + badgeId: badge._id, + name: badge.name, + description: badge.description, + icon: badge.icon, + rarity: badge.rarity + }; + }); + + const userBadgeMap = {}; + userBadges.docs.forEach(userBadge => { + if (!userBadgeMap[userBadge.userId]) { + userBadgeMap[userBadge.userId] = []; + } + + const badgeInfo = badgeMap[userBadge.badgeId]; + if (badgeInfo) { + userBadgeMap[userBadge.userId].push({ + ...badgeInfo, + earnedAt: userBadge.earnedAt, + progress: userBadge.progress + }); + } + }); + + // Update users with badge info + for (const userId in userBadgeMap) { + const userDoc = await this.db.get(userId); + userDoc.earnedBadges = userBadgeMap[userId]; + userDoc.stats.badgesEarned = userBadgeMap[userId].length; + await this.db.insert(userDoc); + } + + console.log('āœ… Statistics calculated'); + } + + async runMigration() { + try { + await this.initialize(); + + // Phase 1: Migrate basic documents + await this.migrateCollection(User, this.transformUser, 'users'); + await this.migrateCollection(Street, this.transformStreet, 'streets'); + await this.migrateCollection(Task, this.transformTask, 'tasks'); + await this.migrateCollection(Post, this.transformPost, 'posts'); + await this.migrateCollection(Event, this.transformEvent, 'events'); + await this.migrateCollection(Report, this.transformReport, 'reports'); + await this.migrateCollection(Badge, this.transformBadge, 'badges'); + await this.migrateCollection(Comment, this.transformComment, 'comments'); + await this.migrateCollection(PointTransaction, this.transformPointTransaction, 'pointTransactions'); + await this.migrateCollection(UserBadge, this.transformUserBadge, 'userBadges'); + + // Phase 2: Populate relationships + await this.populateRelationships(); + + // Phase 3: Calculate statistics + await this.calculateStats(); + + console.log('\nšŸŽ‰ Migration completed!'); + console.log('\nšŸ“ˆ Migration Statistics:'); + console.log(`Users: ${this.migrationStats.users}`); + console.log(`Streets: ${this.migrationStats.streets}`); + console.log(`Tasks: ${this.migrationStats.tasks}`); + console.log(`Posts: ${this.migrationStats.posts}`); + console.log(`Events: ${this.migrationStats.events}`); + console.log(`Reports: ${this.migrationStats.reports}`); + console.log(`Badges: ${this.migrationStats.badges}`); + console.log(`Comments: ${this.migrationStats.comments}`); + console.log(`Point Transactions: ${this.migrationStats.pointTransactions}`); + console.log(`User Badges: ${this.migrationStats.userBadges}`); + + if (this.migrationStats.errors.length > 0) { + console.log('\nāš ļø Errors encountered:'); + this.migrationStats.errors.forEach(error => console.log(` - ${error}`)); + } + + } catch (error) { + console.error('āŒ Migration failed:', error); + throw error; + } finally { + await mongoose.disconnect(); + console.log('šŸ”Œ Disconnected from MongoDB'); + } + } +} + +// Run migration if called directly +if (require.main === module) { + const migrator = new MongoToCouchMigrator(); + migrator.runMigration().catch(console.error); +} + +module.exports = MongoToCouchMigrator; \ No newline at end of file