- Add production-ready CouchDB service with connection management - Implement design documents with views and Mango indexes - Create CRUD operations with proper error handling - Add specialized helper methods for all document types - Include batch operations and conflict resolution - Create comprehensive migration script from MongoDB to CouchDB - Add test suite with graceful handling when CouchDB unavailable - Include detailed documentation and usage guide - Update environment configuration for CouchDB support - Follow existing code patterns and conventions 🤖 Generated with [AI Assistant] Co-Authored-By: AI Assistant <noreply@ai-assistant.com>
552 lines
20 KiB
JavaScript
552 lines
20 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
require("dotenv").config();
|
|
const mongoose = require("mongoose");
|
|
const couchdbService = require("../services/couchdbService");
|
|
|
|
// MongoDB models
|
|
const User = require("../models/User");
|
|
const Street = require("../models/Street");
|
|
const Task = require("../models/Task");
|
|
const Post = require("../models/Post");
|
|
const Event = require("../models/Event");
|
|
const Report = require("../models/Report");
|
|
const Reward = require("../models/Reward");
|
|
const Badge = require("../models/Badge");
|
|
const Comment = require("../models/Comment");
|
|
const PointTransaction = require("../models/PointTransaction");
|
|
const UserBadge = require("../models/UserBadge");
|
|
|
|
class MigrationService {
|
|
constructor() {
|
|
this.stats = {
|
|
users: 0,
|
|
streets: 0,
|
|
tasks: 0,
|
|
posts: 0,
|
|
events: 0,
|
|
reports: 0,
|
|
rewards: 0,
|
|
badges: 0,
|
|
comments: 0,
|
|
pointTransactions: 0,
|
|
userBadges: 0,
|
|
errors: 0
|
|
};
|
|
}
|
|
|
|
async initialize() {
|
|
console.log("🚀 Starting MongoDB to CouchDB migration...");
|
|
|
|
// Connect to MongoDB
|
|
try {
|
|
await mongoose.connect(process.env.MONGO_URI);
|
|
console.log("✅ Connected to MongoDB");
|
|
} catch (error) {
|
|
console.error("❌ Failed to connect to MongoDB:", error.message);
|
|
process.exit(1);
|
|
}
|
|
|
|
// Initialize CouchDB
|
|
try {
|
|
await couchdbService.initialize();
|
|
console.log("✅ Connected to CouchDB");
|
|
} catch (error) {
|
|
console.error("❌ Failed to connect to CouchDB:", error.message);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
// Transform functions for each document type
|
|
transformUser(mongoUser) {
|
|
return {
|
|
_id: couchdbService.generateId("user", mongoUser._id.toString()),
|
|
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 =>
|
|
couchdbService.generateId("street", id.toString())
|
|
),
|
|
completedTasks: (mongoUser.completedTasks || []).map(id =>
|
|
couchdbService.generateId("task", id.toString())
|
|
),
|
|
posts: (mongoUser.posts || []).map(id =>
|
|
couchdbService.generateId("post", id.toString())
|
|
),
|
|
events: (mongoUser.events || []).map(id =>
|
|
couchdbService.generateId("event", id.toString())
|
|
),
|
|
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 // Will be calculated from UserBadge collection
|
|
},
|
|
createdAt: mongoUser.createdAt || new Date().toISOString(),
|
|
updatedAt: mongoUser.updatedAt || new Date().toISOString()
|
|
};
|
|
}
|
|
|
|
transformStreet(mongoStreet) {
|
|
return {
|
|
_id: couchdbService.generateId("street", mongoStreet._id.toString()),
|
|
type: "street",
|
|
name: mongoStreet.name,
|
|
location: mongoStreet.location,
|
|
adoptedBy: mongoStreet.adoptedBy ? {
|
|
userId: couchdbService.generateId("user", mongoStreet.adoptedBy.toString()),
|
|
name: "", // Will be populated in relationship resolution
|
|
profilePicture: ""
|
|
} : null,
|
|
status: mongoStreet.adoptedBy ? "adopted" : "available",
|
|
stats: {
|
|
tasksCount: 0, // Will be calculated from Task collection
|
|
completedTasksCount: 0, // Will be calculated from Task collection
|
|
reportsCount: 0, // Will be calculated from Report collection
|
|
openReportsCount: 0 // Will be calculated from Report collection
|
|
},
|
|
createdAt: mongoStreet.createdAt || new Date().toISOString(),
|
|
updatedAt: mongoStreet.updatedAt || new Date().toISOString()
|
|
};
|
|
}
|
|
|
|
transformTask(mongoTask) {
|
|
return {
|
|
_id: couchdbService.generateId("task", mongoTask._id.toString()),
|
|
type: "task",
|
|
street: {
|
|
streetId: couchdbService.generateId("street", mongoTask.street.toString()),
|
|
name: "", // Will be populated in relationship resolution
|
|
location: null // Will be populated in relationship resolution
|
|
},
|
|
description: mongoTask.description,
|
|
completedBy: mongoTask.completedBy ? {
|
|
userId: couchdbService.generateId("user", mongoTask.completedBy.toString()),
|
|
name: "", // Will be populated in relationship resolution
|
|
profilePicture: ""
|
|
} : null,
|
|
status: mongoTask.completedBy ? "completed" : "pending",
|
|
completedAt: mongoTask.completedAt,
|
|
pointsAwarded: mongoTask.pointsAwarded || 10,
|
|
createdAt: mongoTask.createdAt || new Date().toISOString(),
|
|
updatedAt: mongoTask.updatedAt || new Date().toISOString()
|
|
};
|
|
}
|
|
|
|
transformPost(mongoPost) {
|
|
return {
|
|
_id: couchdbService.generateId("post", mongoPost._id.toString()),
|
|
type: "post",
|
|
user: {
|
|
userId: couchdbService.generateId("user", mongoPost.user.toString()),
|
|
name: "", // Will be populated in relationship resolution
|
|
profilePicture: ""
|
|
},
|
|
content: mongoPost.content,
|
|
imageUrl: mongoPost.imageUrl,
|
|
cloudinaryPublicId: mongoPost.cloudinaryPublicId,
|
|
likes: (mongoPost.likes || []).map(id =>
|
|
couchdbService.generateId("user", id.toString())
|
|
),
|
|
likesCount: (mongoPost.likes || []).length,
|
|
commentsCount: 0, // Will be calculated from Comment collection
|
|
createdAt: mongoPost.createdAt || new Date().toISOString(),
|
|
updatedAt: mongoPost.updatedAt || new Date().toISOString()
|
|
};
|
|
}
|
|
|
|
transformEvent(mongoEvent) {
|
|
return {
|
|
_id: couchdbService.generateId("event", mongoEvent._id.toString()),
|
|
type: "event",
|
|
title: mongoEvent.title,
|
|
description: mongoEvent.description,
|
|
date: mongoEvent.date,
|
|
location: mongoEvent.location,
|
|
participants: (mongoEvent.participants || []).map(userId => ({
|
|
userId: couchdbService.generateId("user", userId.toString()),
|
|
name: "", // Will be populated in relationship resolution
|
|
profilePicture: "",
|
|
joinedAt: new Date().toISOString() // Default join time
|
|
})),
|
|
participantsCount: (mongoEvent.participants || []).length,
|
|
status: mongoEvent.status || "upcoming",
|
|
createdAt: mongoEvent.createdAt || new Date().toISOString(),
|
|
updatedAt: mongoEvent.updatedAt || new Date().toISOString()
|
|
};
|
|
}
|
|
|
|
transformReport(mongoReport) {
|
|
return {
|
|
_id: couchdbService.generateId("report", mongoReport._id.toString()),
|
|
type: "report",
|
|
street: {
|
|
streetId: couchdbService.generateId("street", mongoReport.street.toString()),
|
|
name: "", // Will be populated in relationship resolution
|
|
location: null // Will be populated in relationship resolution
|
|
},
|
|
user: {
|
|
userId: couchdbService.generateId("user", mongoReport.user.toString()),
|
|
name: "", // Will be populated in relationship resolution
|
|
profilePicture: ""
|
|
},
|
|
issue: mongoReport.issue,
|
|
imageUrl: mongoReport.imageUrl,
|
|
cloudinaryPublicId: mongoReport.cloudinaryPublicId,
|
|
status: mongoReport.status || "open",
|
|
createdAt: mongoReport.createdAt || new Date().toISOString(),
|
|
updatedAt: mongoReport.updatedAt || new Date().toISOString()
|
|
};
|
|
}
|
|
|
|
transformBadge(mongoBadge) {
|
|
return {
|
|
_id: couchdbService.generateId("badge", mongoBadge._id.toString()),
|
|
type: "badge",
|
|
name: mongoBadge.name,
|
|
description: mongoBadge.description,
|
|
icon: mongoBadge.icon,
|
|
criteria: mongoBadge.criteria,
|
|
rarity: mongoBadge.rarity || "common",
|
|
order: mongoBadge.order || 0,
|
|
isActive: mongoBadge.isActive !== false,
|
|
createdAt: mongoBadge.createdAt || new Date().toISOString(),
|
|
updatedAt: mongoBadge.updatedAt || new Date().toISOString()
|
|
};
|
|
}
|
|
|
|
transformComment(mongoComment) {
|
|
return {
|
|
_id: couchdbService.generateId("comment", mongoComment._id.toString()),
|
|
type: "comment",
|
|
post: {
|
|
postId: couchdbService.generateId("post", mongoComment.post.toString()),
|
|
content: "", // Will be populated in relationship resolution
|
|
userId: "" // Will be populated in relationship resolution
|
|
},
|
|
user: {
|
|
userId: couchdbService.generateId("user", mongoComment.user.toString()),
|
|
name: "", // Will be populated in relationship resolution
|
|
profilePicture: ""
|
|
},
|
|
content: mongoComment.content,
|
|
createdAt: mongoComment.createdAt || new Date().toISOString(),
|
|
updatedAt: mongoComment.updatedAt || new Date().toISOString()
|
|
};
|
|
}
|
|
|
|
transformPointTransaction(mongoTransaction) {
|
|
return {
|
|
_id: couchdbService.generateId("transaction", mongoTransaction._id.toString()),
|
|
type: "point_transaction",
|
|
user: {
|
|
userId: couchdbService.generateId("user", mongoTransaction.user.toString()),
|
|
name: "" // Will be populated in relationship resolution
|
|
},
|
|
amount: mongoTransaction.amount,
|
|
type: mongoTransaction.type,
|
|
description: mongoTransaction.description,
|
|
relatedEntity: mongoTransaction.relatedEntity ? {
|
|
entityType: mongoTransaction.relatedEntity.entityType,
|
|
entityId: mongoTransaction.relatedEntity.entityId ?
|
|
couchdbService.generateId(
|
|
mongoTransaction.relatedEntity.entityType.toLowerCase(),
|
|
mongoTransaction.relatedEntity.entityId.toString()
|
|
) : null,
|
|
entityName: mongoTransaction.relatedEntity.entityName
|
|
} : null,
|
|
balanceAfter: mongoTransaction.balanceAfter,
|
|
createdAt: mongoTransaction.createdAt || new Date().toISOString()
|
|
};
|
|
}
|
|
|
|
transformUserBadge(mongoUserBadge) {
|
|
return {
|
|
_id: couchdbService.generateId("userbadge", mongoUserBadge._id.toString()),
|
|
type: "user_badge",
|
|
userId: couchdbService.generateId("user", mongoUserBadge.user.toString()),
|
|
badgeId: couchdbService.generateId("badge", mongoUserBadge.badge.toString()),
|
|
progress: mongoUserBadge.progress || 100,
|
|
earnedAt: mongoUserBadge.earnedAt || new Date().toISOString(),
|
|
createdAt: mongoUserBadge.createdAt || new Date().toISOString(),
|
|
updatedAt: mongoUserBadge.updatedAt || new Date().toISOString()
|
|
};
|
|
}
|
|
|
|
// Migration methods
|
|
async migrateCollection(mongoModel, transformFn, collectionName) {
|
|
console.log(`📦 Migrating ${collectionName}...`);
|
|
|
|
try {
|
|
const documents = await mongoModel.find({});
|
|
console.log(`Found ${documents.length} ${collectionName} documents`);
|
|
|
|
const transformedDocs = documents.map(doc => transformFn(doc));
|
|
|
|
// Batch insert
|
|
if (transformedDocs.length > 0) {
|
|
const result = await couchdbService.bulkDocs({ docs: transformedDocs });
|
|
|
|
// Count successful migrations
|
|
const successful = result.filter(r => r.ok).length;
|
|
this.stats[collectionName] = successful;
|
|
|
|
console.log(`✅ Successfully migrated ${successful}/${transformedDocs.length} ${collectionName}`);
|
|
|
|
if (successful < transformedDocs.length) {
|
|
console.log(`⚠️ ${transformedDocs.length - successful} ${collectionName} failed to migrate`);
|
|
this.stats.errors += transformedDocs.length - successful;
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error(`❌ Error migrating ${collectionName}:`, error.message);
|
|
this.stats.errors++;
|
|
}
|
|
}
|
|
|
|
async resolveRelationships() {
|
|
console.log("🔗 Resolving relationships and populating embedded data...");
|
|
|
|
try {
|
|
// Get all users for lookup
|
|
const users = await couchdbService.findByType("user");
|
|
const userMap = new Map(users.map(u => [u._id, u]));
|
|
|
|
// Get all streets for lookup
|
|
const streets = await couchdbService.findByType("street");
|
|
const streetMap = new Map(streets.map(s => [s._id, s]));
|
|
|
|
// Get all posts for lookup
|
|
const posts = await couchdbService.findByType("post");
|
|
const postMap = new Map(posts.map(p => [p._id, p]));
|
|
|
|
// Update streets with adopter info
|
|
for (const street of streets) {
|
|
if (street.adoptedBy && street.adoptedBy.userId) {
|
|
const user = userMap.get(street.adoptedBy.userId);
|
|
if (user) {
|
|
street.adoptedBy.name = user.name;
|
|
street.adoptedBy.profilePicture = user.profilePicture || "";
|
|
}
|
|
}
|
|
}
|
|
|
|
// Update tasks with street and user info
|
|
const tasks = await couchdbService.findByType("task");
|
|
for (const task of tasks) {
|
|
if (task.street && task.street.streetId) {
|
|
const street = streetMap.get(task.street.streetId);
|
|
if (street) {
|
|
task.street.name = street.name;
|
|
task.street.location = street.location;
|
|
}
|
|
}
|
|
|
|
if (task.completedBy && task.completedBy.userId) {
|
|
const user = userMap.get(task.completedBy.userId);
|
|
if (user) {
|
|
task.completedBy.name = user.name;
|
|
task.completedBy.profilePicture = user.profilePicture || "";
|
|
}
|
|
}
|
|
}
|
|
|
|
// Update posts with user info
|
|
for (const post of posts) {
|
|
if (post.user && post.user.userId) {
|
|
const user = userMap.get(post.user.userId);
|
|
if (user) {
|
|
post.user.name = user.name;
|
|
post.user.profilePicture = user.profilePicture || "";
|
|
}
|
|
}
|
|
}
|
|
|
|
// Update comments with post and user info
|
|
const comments = await couchdbService.findByType("comment");
|
|
for (const comment of comments) {
|
|
if (comment.post && comment.post.postId) {
|
|
const post = postMap.get(comment.post.postId);
|
|
if (post) {
|
|
comment.post.content = post.content;
|
|
comment.post.userId = post.user.userId;
|
|
}
|
|
}
|
|
|
|
if (comment.user && comment.user.userId) {
|
|
const user = userMap.get(comment.user.userId);
|
|
if (user) {
|
|
comment.user.name = user.name;
|
|
comment.user.profilePicture = user.profilePicture || "";
|
|
}
|
|
}
|
|
}
|
|
|
|
// Update events with participant info
|
|
const events = await couchdbService.findByType("event");
|
|
for (const event of events) {
|
|
for (const participant of event.participants) {
|
|
const user = userMap.get(participant.userId);
|
|
if (user) {
|
|
participant.name = user.name;
|
|
participant.profilePicture = user.profilePicture || "";
|
|
}
|
|
}
|
|
}
|
|
|
|
// Update reports with street and user info
|
|
const reports = await couchdbService.findByType("report");
|
|
for (const report of reports) {
|
|
if (report.street && report.street.streetId) {
|
|
const street = streetMap.get(report.street.streetId);
|
|
if (street) {
|
|
report.street.name = street.name;
|
|
report.street.location = street.location;
|
|
}
|
|
}
|
|
|
|
if (report.user && report.user.userId) {
|
|
const user = userMap.get(report.user.userId);
|
|
if (user) {
|
|
report.user.name = user.name;
|
|
report.user.profilePicture = user.profilePicture || "";
|
|
}
|
|
}
|
|
}
|
|
|
|
// Update point transactions with user info
|
|
const transactions = await couchdbService.findByType("point_transaction");
|
|
for (const transaction of transactions) {
|
|
if (transaction.user && transaction.user.userId) {
|
|
const user = userMap.get(transaction.user.userId);
|
|
if (user) {
|
|
transaction.user.name = user.name;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Update user badges and calculate stats
|
|
const userBadges = await couchdbService.findByType("user_badge");
|
|
const badgeMap = new Map();
|
|
|
|
// Create badge lookup
|
|
const badges = await couchdbService.findByType("badge");
|
|
for (const badge of badges) {
|
|
badgeMap.set(badge._id, badge);
|
|
}
|
|
|
|
// Update users with badge info
|
|
for (const user of users) {
|
|
const userBadgeDocs = userBadges.filter(ub => ub.userId === user._id);
|
|
user.earnedBadges = userBadgeDocs.map(ub => {
|
|
const badge = badgeMap.get(ub.badgeId);
|
|
return {
|
|
badgeId: ub.badgeId,
|
|
name: badge ? badge.name : "Unknown Badge",
|
|
description: badge ? badge.description : "",
|
|
icon: badge ? badge.icon : "🏆",
|
|
rarity: badge ? badge.rarity : "common",
|
|
earnedAt: ub.earnedAt,
|
|
progress: ub.progress
|
|
};
|
|
});
|
|
user.stats.badgesEarned = user.earnedBadges.length;
|
|
}
|
|
|
|
// Calculate stats for streets
|
|
for (const street of streets) {
|
|
const streetTasks = tasks.filter(t => t.street && t.street.streetId === street._id);
|
|
const streetReports = reports.filter(r => r.street && r.street.streetId === street._id);
|
|
|
|
street.stats.tasksCount = streetTasks.length;
|
|
street.stats.completedTasksCount = streetTasks.filter(t => t.status === "completed").length;
|
|
street.stats.reportsCount = streetReports.length;
|
|
street.stats.openReportsCount = streetReports.filter(r => r.status === "open").length;
|
|
}
|
|
|
|
// Calculate comment counts for posts
|
|
for (const post of posts) {
|
|
const postComments = comments.filter(c => c.post && c.post.postId === post._id);
|
|
post.commentsCount = postComments.length;
|
|
}
|
|
|
|
// Batch update all documents
|
|
const allDocs = [
|
|
...streets,
|
|
...tasks,
|
|
...posts,
|
|
...comments,
|
|
...events,
|
|
...reports,
|
|
...transactions,
|
|
...users
|
|
];
|
|
|
|
if (allDocs.length > 0) {
|
|
const result = await couchdbService.bulkDocs({ docs: allDocs });
|
|
const successful = result.filter(r => r.ok).length;
|
|
console.log(`✅ Successfully updated ${successful}/${allDocs.length} documents with relationship data`);
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error("❌ Error resolving relationships:", error.message);
|
|
this.stats.errors++;
|
|
}
|
|
}
|
|
|
|
async runMigration() {
|
|
await this.initialize();
|
|
|
|
try {
|
|
// Phase 1: Migrate all collections
|
|
await this.migrateCollection(User, this.transformUser.bind(this), "users");
|
|
await this.migrateCollection(Street, this.transformStreet.bind(this), "streets");
|
|
await this.migrateCollection(Task, this.transformTask.bind(this), "tasks");
|
|
await this.migrateCollection(Post, this.transformPost.bind(this), "posts");
|
|
await this.migrateCollection(Event, this.transformEvent.bind(this), "events");
|
|
await this.migrateCollection(Report, this.transformReport.bind(this), "reports");
|
|
await this.migrateCollection(Badge, this.transformBadge.bind(this), "badges");
|
|
await this.migrateCollection(Comment, this.transformComment.bind(this), "comments");
|
|
await this.migrateCollection(PointTransaction, this.transformPointTransaction.bind(this), "pointTransactions");
|
|
await this.migrateCollection(UserBadge, this.transformUserBadge.bind(this), "userBadges");
|
|
|
|
// Phase 2: Resolve relationships
|
|
await this.resolveRelationships();
|
|
|
|
// Print final statistics
|
|
console.log("\n📊 Migration Summary:");
|
|
console.log("====================");
|
|
Object.entries(this.stats).forEach(([key, value]) => {
|
|
if (key !== "errors") {
|
|
console.log(`${key}: ${value}`);
|
|
}
|
|
});
|
|
if (this.stats.errors > 0) {
|
|
console.log(`❌ Errors: ${this.stats.errors}`);
|
|
}
|
|
console.log("====================");
|
|
console.log("✅ Migration completed!");
|
|
|
|
} catch (error) {
|
|
console.error("❌ Migration failed:", error.message);
|
|
} finally {
|
|
await mongoose.disconnect();
|
|
await couchdbService.shutdown();
|
|
}
|
|
}
|
|
}
|
|
|
|
// Run migration if this script is executed directly
|
|
if (require.main === module) {
|
|
const migration = new MigrationService();
|
|
migration.runMigration().catch(console.error);
|
|
}
|
|
|
|
module.exports = MigrationService; |