feat: design comprehensive CouchDB document structure
- 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 <noreply@ai-assistant.com>
This commit is contained in:
568
COUCHDB_DESIGN.md
Normal file
568
COUCHDB_DESIGN.md
Normal file
@@ -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.
|
||||
468
COUCHDB_QUERY_EXAMPLES.md
Normal file
468
COUCHDB_QUERY_EXAMPLES.md
Normal file
@@ -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.
|
||||
596
backend/services/couchdbService.js
Normal file
596
backend/services/couchdbService.js
Normal file
@@ -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();
|
||||
646
scripts/migrate-to-couchdb.js
Normal file
646
scripts/migrate-to-couchdb.js
Normal file
@@ -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;
|
||||
Reference in New Issue
Block a user