Files
adopt-a-street/scripts/migrate-to-couchdb.js
William Valentin e74de09605 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>
2025-11-01 12:57:49 -07:00

646 lines
20 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;