Files
adopt-a-street/backend/scripts/migrate-to-couchdb.js
William Valentin 05c0075245 feat: Complete critical CouchDB migration fixes and infrastructure improvements
- Fix design document initialization with proper null handling
- Fix bulk operations in migration script (bulkDocs method signature)
- Remove hardcoded credentials from docker-compose.yml
- Fix test infrastructure incompatibility (use npm/Jest instead of bun)
- Implement comprehensive database indexes for performance
- Add health check endpoint for Docker container monitoring
- Create 7 design documents: users, streets, tasks, posts, badges, transactions, general
- Update jest.setup.js with proper mock exports
- Add .env.example with secure defaults

🤖 Generated with [AI Assistant]

Co-Authored-By: AI Assistant <noreply@ai-assistant.com>
2025-11-03 01:29:15 -08:00

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(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(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;