feat: complete MongoDB to CouchDB migration

- Migrate Report model to CouchDB with embedded street/user data
- Migrate UserBadge model to CouchDB with badge population
- Update all remaining routes (reports, users, badges, payments) to use CouchDB
- Add CouchDB health check and graceful shutdown to server.js
- Add missing methods to couchdbService (checkConnection, findWithPagination, etc.)
- Update Kubernetes deployment manifests for CouchDB support
- Add comprehensive CouchDB setup documentation

All core functionality now uses CouchDB as primary database while maintaining
MongoDB for backward compatibility during transition period.

🤖 Generated with [AI Assistant]

Co-Authored-By: AI Assistant <noreply@ai-assistant.com>
This commit is contained in:
William Valentin
2025-11-01 13:29:48 -07:00
parent 9ac21fca72
commit df94c17e1f
14 changed files with 684 additions and 155 deletions

View File

@@ -1,41 +1,109 @@
const mongoose = require("mongoose");
const couchdbService = require("../services/couchdbService");
const ReportSchema = new mongoose.Schema(
{
street: {
type: mongoose.Schema.Types.ObjectId,
ref: "Street",
required: true,
},
user: {
type: mongoose.Schema.Types.ObjectId,
ref: "User",
required: true,
},
issue: {
type: String,
required: true,
},
imageUrl: {
type: String,
},
cloudinaryPublicId: {
type: String,
},
status: {
type: String,
enum: ["open", "resolved"],
default: "open",
},
},
{
timestamps: true,
},
);
class Report {
static async create(reportData) {
const doc = {
type: "report",
...reportData,
status: reportData.status || "open",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
// Indexes for performance
ReportSchema.index({ street: 1, status: 1 });
ReportSchema.index({ user: 1 });
ReportSchema.index({ createdAt: -1 });
return await couchdbService.createDocument(doc);
}
module.exports = mongoose.model("Report", ReportSchema);
static async findById(id) {
const doc = await couchdbService.getDocument(id);
if (doc && doc.type === "report") {
return doc;
}
return null;
}
static async find(filter = {}) {
const selector = {
type: "report",
...filter,
};
return await couchdbService.findDocuments(selector);
}
static async findWithPagination(options = {}) {
const { page = 1, limit = 10, sort = { createdAt: -1 } } = options;
const selector = { type: "report" };
return await couchdbService.findWithPagination(selector, {
page,
limit,
sort,
});
}
static async update(id, updateData) {
const doc = await couchdbService.getDocument(id);
if (!doc || doc.type !== "report") {
throw new Error("Report not found");
}
const updatedDoc = {
...doc,
...updateData,
updatedAt: new Date().toISOString(),
};
return await couchdbService.updateDocument(id, updatedDoc);
}
static async delete(id) {
const doc = await couchdbService.getDocument(id);
if (!doc || doc.type !== "report") {
throw new Error("Report not found");
}
return await couchdbService.deleteDocument(id, doc._rev);
}
static async countDocuments(filter = {}) {
const selector = {
type: "report",
...filter,
};
return await couchdbService.countDocuments(selector);
}
static async findByStreet(streetId) {
const selector = {
type: "report",
"street._id": streetId,
};
return await couchdbService.findDocuments(selector);
}
static async findByUser(userId) {
const selector = {
type: "report",
"user._id": userId,
};
return await couchdbService.findDocuments(selector);
}
static async findByStatus(status) {
const selector = {
type: "report",
status,
};
return await couchdbService.findDocuments(selector);
}
static async update(id, updateData) {
return await couchdbService.update(id, updateData);
}
}
module.exports = Report;

View File

@@ -1,37 +1,118 @@
const mongoose = require("mongoose");
const couchdbService = require("../services/couchdbService");
const UserBadgeSchema = new mongoose.Schema(
{
user: {
type: mongoose.Schema.Types.ObjectId,
ref: "User",
required: true,
index: true,
},
badge: {
type: mongoose.Schema.Types.ObjectId,
ref: "Badge",
required: true,
index: true,
},
earnedAt: {
type: Date,
default: Date.now,
},
progress: {
type: Number,
default: 0,
},
},
{
timestamps: true,
},
);
class UserBadge {
static async create(userBadgeData) {
const doc = {
type: "user_badge",
...userBadgeData,
earnedAt: userBadgeData.earnedAt || new Date().toISOString(),
progress: userBadgeData.progress || 0,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
// Compound unique index to prevent duplicate badge awards
UserBadgeSchema.index({ user: 1, badge: 1 }, { unique: true });
return await couchdbService.createDocument(doc);
}
// Index for user badge queries
UserBadgeSchema.index({ user: 1, earnedAt: -1 });
static async findById(id) {
const doc = await couchdbService.getDocument(id);
if (doc && doc.type === "user_badge") {
return doc;
}
return null;
}
module.exports = mongoose.model("UserBadge", UserBadgeSchema);
static async find(filter = {}) {
const selector = {
type: "user_badge",
...filter,
};
return await couchdbService.findDocuments(selector);
}
static async findByUser(userId) {
const selector = {
type: "user_badge",
userId: userId,
};
const userBadges = await couchdbService.findDocuments(selector);
// Populate badge data for each user badge
const populatedBadges = await Promise.all(
userBadges.map(async (userBadge) => {
if (userBadge.badgeId) {
const badge = await couchdbService.getDocument(userBadge.badgeId);
return {
...userBadge,
badge: badge,
};
}
return userBadge;
})
);
return populatedBadges;
}
static async findByBadge(badgeId) {
const selector = {
type: "user_badge",
badgeId: badgeId,
};
return await couchdbService.findDocuments(selector);
}
static async update(id, updateData) {
const doc = await couchdbService.getDocument(id);
if (!doc || doc.type !== "user_badge") {
throw new Error("UserBadge not found");
}
const updatedDoc = {
...doc,
...updateData,
updatedAt: new Date().toISOString(),
};
return await couchdbService.updateDocument(id, updatedDoc);
}
static async delete(id) {
const doc = await couchdbService.getDocument(id);
if (!doc || doc.type !== "user_badge") {
throw new Error("UserBadge not found");
}
return await couchdbService.deleteDocument(id, doc._rev);
}
static async findByUserAndBadge(userId, badgeId) {
const selector = {
type: "user_badge",
userId: userId,
badgeId: badgeId,
};
const results = await couchdbService.findDocuments(selector);
return results[0] || null;
}
static async updateProgress(userId, badgeId, progress) {
const userBadge = await this.findByUserAndBadge(userId, badgeId);
if (userBadge) {
return await this.update(userBadge._id, { progress });
} else {
return await this.create({
userId,
badgeId,
progress,
});
}
}
}
module.exports = UserBadge;

View File

@@ -14,7 +14,12 @@ const router = express.Router();
router.get(
"/",
asyncHandler(async (req, res) => {
const badges = await Badge.find().sort({ order: 1, rarity: 1 });
const badges = await Badge.find({ type: "badge" });
// Sort by order and rarity in JavaScript since CouchDB doesn't support complex sorting
badges.sort((a, b) => {
if (a.order !== b.order) return a.order - b.order;
return a.rarity.localeCompare(b.rarity);
});
res.json(badges);
})
);
@@ -33,7 +38,7 @@ router.get(
);
/**
* GET /api/users/:userId/badges
* GET /api/badges/users/:userId
* Get badges earned by a specific user
*/
router.get(
@@ -41,9 +46,10 @@ router.get(
asyncHandler(async (req, res) => {
const { userId } = req.params;
const userBadges = await UserBadge.find({ user: userId })
.populate("badge")
.sort({ earnedAt: -1 });
const userBadges = await UserBadge.findByUser(userId);
// Sort by earnedAt in JavaScript
userBadges.sort((a, b) => new Date(b.earnedAt) - new Date(a.earnedAt));
res.json(
userBadges.map((ub) => ({

View File

@@ -14,8 +14,7 @@ router.post("/subscribe", auth, async (req, res) => {
return res.status(404).json({ msg: "User not found" });
}
user.isPremium = true;
await user.save();
await User.update(req.user.id, { isPremium: true });
res.json({ msg: "Subscription successful" });
} catch (err) {

View File

@@ -1,5 +1,7 @@
const express = require("express");
const Report = require("../models/Report");
const User = require("../models/User");
const Street = require("../models/Street");
const auth = require("../middleware/auth");
const { asyncHandler } = require("../middleware/errorHandler");
const {
@@ -15,23 +17,23 @@ const router = express.Router();
router.get(
"/",
asyncHandler(async (req, res) => {
const { paginate, buildPaginatedResponse } = require("../middleware/pagination");
// Parse pagination params
const page = parseInt(req.query.page) || 1;
const limit = Math.min(parseInt(req.query.limit) || 10, 100);
const skip = (page - 1) * limit;
const reports = await Report.find()
.sort({ createdAt: -1 })
.skip(skip)
.limit(limit)
.populate("street", ["name"])
.populate("user", ["name", "profilePicture"]);
const result = await Report.findWithPagination({
page,
limit,
sort: { createdAt: -1 },
});
const totalCount = await Report.countDocuments();
res.json(buildPaginatedResponse(reports, totalCount, page, limit));
res.json({
reports: result.docs,
totalCount: result.totalDocs,
currentPage: result.page,
totalPages: result.totalPages,
hasNext: result.hasNextPage,
hasPrev: result.hasPrevPage,
});
}),
);
@@ -43,11 +45,29 @@ router.post(
handleUploadError,
createReportValidation,
asyncHandler(async (req, res) => {
const { street, issue } = req.body;
const { street: streetId, issue } = req.body;
// Get street and user data for embedding
const street = await Street.findById(streetId);
if (!street) {
return res.status(404).json({ msg: "Street not found" });
}
const user = await User.findById(req.user.id);
if (!user) {
return res.status(404).json({ msg: "User not found" });
}
const reportData = {
street,
user: req.user.id,
street: {
_id: street._id,
name: street.name,
},
user: {
_id: user._id,
name: user.name,
profilePicture: user.profilePicture,
},
issue,
};
@@ -61,15 +81,7 @@ router.post(
reportData.cloudinaryPublicId = result.publicId;
}
const newReport = new Report(reportData);
const report = await newReport.save();
// Populate user and street data
await report.populate([
{ path: "user", select: "name profilePicture" },
{ path: "street", select: "name" },
]);
const report = await Report.create(reportData);
res.json(report);
}),
);
@@ -88,7 +100,7 @@ router.post(
}
// Verify user owns the report
if (report.user.toString() !== req.user.id) {
if (report.user._id !== req.user.id) {
return res.status(403).json({ msg: "Not authorized" });
}
@@ -107,11 +119,12 @@ router.post(
"adopt-a-street/reports",
);
report.imageUrl = result.url;
report.cloudinaryPublicId = result.publicId;
await report.save();
const updatedReport = await Report.update(req.params.id, {
imageUrl: result.url,
cloudinaryPublicId: result.publicId,
});
res.json(report);
res.json(updatedReport);
}),
);
@@ -126,11 +139,11 @@ router.put(
return res.status(404).json({ msg: "Report not found" });
}
report.status = "resolved";
const updatedReport = await Report.update(req.params.id, {
status: "resolved",
});
await report.save();
res.json(report);
res.json(updatedReport);
}),
);

View File

@@ -1,5 +1,6 @@
const express = require("express");
const User = require("../models/User");
const Street = require("../models/Street");
const auth = require("../middleware/auth");
const { asyncHandler } = require("../middleware/errorHandler");
const { userIdValidation } = require("../middleware/validators/userValidator");
@@ -14,11 +15,34 @@ router.get(
auth,
userIdValidation,
asyncHandler(async (req, res) => {
const user = await User.findById(req.params.id).populate("adoptedStreets");
const user = await User.findById(req.params.id);
if (!user) {
return res.status(404).json({ msg: "User not found" });
}
res.json(user);
// Get adopted streets data
let adoptedStreets = [];
if (user.adoptedStreets && user.adoptedStreets.length > 0) {
adoptedStreets = await Promise.all(
user.adoptedStreets.map(async (streetId) => {
const street = await Street.findById(streetId);
return street ? {
_id: street._id,
name: street.name,
location: street.location,
status: street.status,
} : null;
})
);
adoptedStreets = adoptedStreets.filter(Boolean);
}
const userWithStreets = {
...user,
adoptedStreets,
};
res.json(userWithStreets);
}),
);
@@ -50,13 +74,14 @@ router.post(
);
// Update user with new profile picture
user.profilePicture = result.url;
user.cloudinaryPublicId = result.publicId;
await user.save();
const updatedUser = await User.update(req.user.id, {
profilePicture: result.url,
cloudinaryPublicId: result.publicId,
});
res.json({
msg: "Profile picture updated successfully",
profilePicture: user.profilePicture,
profilePicture: updatedUser.profilePicture,
});
}),
);
@@ -79,9 +104,10 @@ router.delete(
await deleteImage(user.cloudinaryPublicId);
// Remove from user
user.profilePicture = undefined;
user.cloudinaryPublicId = undefined;
await user.save();
await User.update(req.user.id, {
profilePicture: undefined,
cloudinaryPublicId: undefined,
});
res.json({ msg: "Profile picture deleted successfully" });
}),

View File

@@ -72,7 +72,10 @@ mongoose
// CouchDB (primary database)
couchdbService.initialize()
.then(() => console.log("CouchDB initialized"))
.catch((err) => console.log("CouchDB initialization error:", err));
.catch((err) => {
console.log("CouchDB initialization error:", err);
process.exit(1); // Exit if CouchDB fails to initialize since it's the primary database
});
// Socket.IO Authentication Middleware
io.use(socketAuth);
@@ -123,13 +126,27 @@ app.use("/api/auth/login", authLimiter);
app.use("/api", apiLimiter);
// Health check endpoint (for Kubernetes liveness/readiness probes)
app.get("/api/health", (req, res) => {
res.status(200).json({
status: "healthy",
timestamp: new Date().toISOString(),
uptime: process.uptime(),
mongodb: mongoose.connection.readyState === 1 ? "connected" : "disconnected",
});
app.get("/api/health", async (req, res) => {
try {
const couchdbStatus = await couchdbService.checkConnection();
res.status(200).json({
status: "healthy",
timestamp: new Date().toISOString(),
uptime: process.uptime(),
mongodb: mongoose.connection.readyState === 1 ? "connected" : "disconnected",
couchdb: couchdbStatus ? "connected" : "disconnected",
});
} catch (error) {
res.status(503).json({
status: "unhealthy",
timestamp: new Date().toISOString(),
uptime: process.uptime(),
mongodb: mongoose.connection.readyState === 1 ? "connected" : "disconnected",
couchdb: "disconnected",
error: error.message,
});
}
});
// Routes
@@ -156,3 +173,42 @@ app.use(errorHandler);
server.listen(port, () => {
console.log(`Server running on port ${port}`);
});
// Graceful shutdown
process.on("SIGTERM", async () => {
console.log("SIGTERM received, shutting down gracefully");
try {
// Close MongoDB connection
await mongoose.connection.close();
console.log("MongoDB connection closed");
// Close server
server.close(() => {
console.log("Server closed");
process.exit(0);
});
} catch (error) {
console.error("Error during shutdown:", error);
process.exit(1);
}
});
process.on("SIGINT", async () => {
console.log("SIGINT received, shutting down gracefully");
try {
// Close MongoDB connection
await mongoose.connection.close();
console.log("MongoDB connection closed");
// Close server
server.close(() => {
console.log("Server closed");
process.exit(0);
});
} catch (error) {
console.error("Error during shutdown:", error);
process.exit(1);
}
});

View File

@@ -412,6 +412,22 @@ class CouchDBService {
return this.isConnected;
}
/**
* Check connection health
*/
async checkConnection() {
try {
if (!this.connection) {
return false;
}
await this.connection.info();
return true;
} catch (error) {
console.error("CouchDB connection check failed:", error.message);
return false;
}
}
// Generic CRUD operations
async createDocument(doc) {
if (!this.isConnected) await this.initialize();
@@ -498,6 +514,62 @@ class CouchDBService {
return await this.find(query);
}
async findDocuments(selector = {}, options = {}) {
const query = {
selector,
...options
};
return await this.find(query);
}
async countDocuments(selector = {}) {
const query = {
selector,
limit: 0, // We don't need documents, just count
};
try {
const response = await this.db.find(query);
return response.total_rows || 0;
} catch (error) {
console.error("Error counting documents:", error.message);
throw error;
}
}
async findWithPagination(selector = {}, options = {}) {
const { page = 1, limit = 10, sort = {} } = options;
const skip = (page - 1) * limit;
const query = {
selector,
limit,
skip,
sort: Object.entries(sort).map(([field, order]) => ({
[field]: order === -1 ? "desc" : "asc"
}))
};
try {
const response = await this.db.find(query);
const totalCount = response.total_rows || 0;
const totalPages = Math.ceil(totalCount / limit);
return {
docs: response.docs,
totalDocs: totalCount,
page,
limit,
totalPages,
hasNextPage: page < totalPages,
hasPrevPage: page > 1,
};
} catch (error) {
console.error("Error finding documents with pagination:", error.message);
throw error;
}
}
// View query helper
async view(designDoc, viewName, params = {}) {
if (!this.isConnected) await this.initialize();