- 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>
1220 lines
33 KiB
JavaScript
1220 lines
33 KiB
JavaScript
const nano = require("nano");
|
|
|
|
class CouchDBService {
|
|
constructor() {
|
|
this.connection = null;
|
|
this.db = null;
|
|
this.dbName = null;
|
|
this.isConnected = false;
|
|
this.isConnecting = false;
|
|
}
|
|
|
|
/**
|
|
* Initialize CouchDB connection and database
|
|
*/
|
|
async initialize() {
|
|
if (this.isConnected || this.isConnecting) {
|
|
return this.isConnected;
|
|
}
|
|
|
|
this.isConnecting = true;
|
|
|
|
try {
|
|
// Get configuration from environment variables
|
|
const couchdbUrl = process.env.COUCHDB_URL || "http://localhost:5984";
|
|
this.dbName = process.env.COUCHDB_DB_NAME || "adopt-a-street";
|
|
|
|
console.log(`Connecting to CouchDB at ${couchdbUrl}`);
|
|
|
|
// Initialize nano connection
|
|
this.connection = nano(couchdbUrl);
|
|
|
|
// Test connection
|
|
await this.connection.info();
|
|
console.log("CouchDB connection established");
|
|
|
|
// Get or create database
|
|
this.db = this.connection.use(this.dbName);
|
|
|
|
try {
|
|
await this.db.info();
|
|
console.log(`Database '${this.dbName}' exists`);
|
|
} catch (error) {
|
|
if (error.statusCode === 404) {
|
|
console.log(`Creating database '${this.dbName}'`);
|
|
await this.connection.db.create(this.dbName);
|
|
this.db = this.connection.use(this.dbName);
|
|
console.log(`Database '${this.dbName}' created successfully`);
|
|
} else {
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// Initialize design documents and indexes
|
|
await this.initializeDesignDocuments();
|
|
|
|
this.isConnected = true;
|
|
console.log("CouchDB service initialized successfully");
|
|
|
|
return true;
|
|
} catch (error) {
|
|
console.error("Failed to initialize CouchDB:", error.message);
|
|
this.isConnected = false;
|
|
this.isConnecting = false;
|
|
throw error;
|
|
} finally {
|
|
this.isConnecting = false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Initialize design documents with indexes and views
|
|
*/
|
|
async initializeDesignDocuments() {
|
|
const designDocs = [
|
|
{
|
|
_id: "_design/users",
|
|
views: {
|
|
"by-email": {
|
|
map: `function(doc) {
|
|
if (doc.type === "user" && doc.email) {
|
|
emit(doc.email, null);
|
|
}
|
|
}`
|
|
},
|
|
"by-points": {
|
|
map: `function(doc) {
|
|
if (doc.type === "user" && doc.points > 0) {
|
|
emit(doc.points, doc);
|
|
}
|
|
}`
|
|
}
|
|
},
|
|
indexes: {
|
|
"user-by-email": {
|
|
index: { fields: ["type", "email"] },
|
|
name: "user-by-email",
|
|
type: "json"
|
|
},
|
|
"users-by-points": {
|
|
index: { fields: ["type", "points"] },
|
|
name: "users-by-points",
|
|
type: "json"
|
|
}
|
|
}
|
|
},
|
|
{
|
|
_id: "_design/streets",
|
|
views: {
|
|
"by-status": {
|
|
map: `function(doc) {
|
|
if (doc.type === "street" && doc.status) {
|
|
emit(doc.status, doc);
|
|
}
|
|
}`
|
|
},
|
|
"by-adopter": {
|
|
map: `function(doc) {
|
|
if (doc.type === "street" && doc.adoptedBy && doc.adoptedBy.userId) {
|
|
emit(doc.adoptedBy.userId, doc);
|
|
}
|
|
}`
|
|
}
|
|
},
|
|
indexes: {
|
|
"streets-by-location": {
|
|
index: {
|
|
fields: ["type", "location"],
|
|
partial_filter_selector: { "type": "street" }
|
|
},
|
|
name: "streets-by-location",
|
|
type: "json"
|
|
},
|
|
"streets-by-status": {
|
|
index: {
|
|
fields: ["type", "status"],
|
|
partial_filter_selector: { "type": "street" }
|
|
},
|
|
name: "streets-by-status",
|
|
type: "json"
|
|
},
|
|
"streets-geo": {
|
|
index: {
|
|
fields: ["type", "location"],
|
|
partial_filter_selector: {
|
|
"type": "street",
|
|
"location": { "$exists": true }
|
|
}
|
|
},
|
|
name: "streets-geo",
|
|
type: "json"
|
|
}
|
|
}
|
|
},
|
|
{
|
|
_id: "_design/tasks",
|
|
views: {
|
|
"by-street": {
|
|
map: `function(doc) {
|
|
if (doc.type === "task" && doc.street && doc.street.streetId) {
|
|
emit(doc.street.streetId, doc);
|
|
}
|
|
}`
|
|
},
|
|
"by-completer": {
|
|
map: `function(doc) {
|
|
if (doc.type === "task" && doc.completedBy && doc.completedBy.userId) {
|
|
emit(doc.completedBy.userId, doc);
|
|
}
|
|
}`
|
|
},
|
|
"by-status": {
|
|
map: `function(doc) {
|
|
if (doc.type === "task" && doc.status) {
|
|
emit(doc.status, doc);
|
|
}
|
|
}`
|
|
}
|
|
},
|
|
indexes: {
|
|
"tasks-by-user": {
|
|
index: { fields: ["type", "completedBy.userId"] },
|
|
name: "tasks-by-user",
|
|
type: "json"
|
|
}
|
|
}
|
|
},
|
|
{
|
|
_id: "_design/posts",
|
|
views: {
|
|
"by-user": {
|
|
map: `function(doc) {
|
|
if (doc.type === "post" && doc.user && doc.user.userId) {
|
|
emit(doc.user.userId, doc);
|
|
}
|
|
}`
|
|
},
|
|
"by-date": {
|
|
map: `function(doc) {
|
|
if (doc.type === "post" && doc.createdAt) {
|
|
emit(doc.createdAt, doc);
|
|
}
|
|
}`
|
|
}
|
|
},
|
|
indexes: {
|
|
"posts-by-date": {
|
|
index: { fields: ["type", "createdAt"] },
|
|
name: "posts-by-date",
|
|
type: "json"
|
|
}
|
|
}
|
|
},
|
|
{
|
|
_id: "_design/comments",
|
|
views: {
|
|
"by-post": {
|
|
map: `function(doc) {
|
|
if (doc.type === "comment" && doc.post && doc.post.postId) {
|
|
emit(doc.post.postId, doc);
|
|
}
|
|
}`
|
|
},
|
|
"by-user": {
|
|
map: `function(doc) {
|
|
if (doc.type === "comment" && doc.user && doc.user.userId) {
|
|
emit(doc.user.userId, doc);
|
|
}
|
|
}`
|
|
}
|
|
},
|
|
indexes: {
|
|
"comments-by-post": {
|
|
index: { fields: ["type", "post.postId"] },
|
|
name: "comments-by-post",
|
|
type: "json"
|
|
}
|
|
}
|
|
},
|
|
{
|
|
_id: "_design/events",
|
|
views: {
|
|
"by-date": {
|
|
map: `function(doc) {
|
|
if (doc.type === "event" && doc.date) {
|
|
emit(doc.date, doc);
|
|
}
|
|
}`
|
|
},
|
|
"by-participant": {
|
|
map: `function(doc) {
|
|
if (doc.type === "event" && doc.participants) {
|
|
doc.participants.forEach(function(participant) {
|
|
emit(participant.userId, doc);
|
|
});
|
|
}
|
|
}`
|
|
}
|
|
},
|
|
indexes: {
|
|
"events-by-date-status": {
|
|
index: { fields: ["type", "date", "status"] },
|
|
name: "events-by-date-status",
|
|
type: "json"
|
|
}
|
|
}
|
|
},
|
|
{
|
|
_id: "_design/reports",
|
|
views: {
|
|
"by-street": {
|
|
map: `function(doc) {
|
|
if (doc.type === "report" && doc.street && doc.street.streetId) {
|
|
emit(doc.street.streetId, doc);
|
|
}
|
|
}`
|
|
},
|
|
"by-status": {
|
|
map: `function(doc) {
|
|
if (doc.type === "report" && doc.status) {
|
|
emit(doc.status, doc);
|
|
}
|
|
}`
|
|
}
|
|
},
|
|
indexes: {
|
|
"reports-by-status": {
|
|
index: { fields: ["type", "status"] },
|
|
name: "reports-by-status",
|
|
type: "json"
|
|
}
|
|
}
|
|
},
|
|
{
|
|
_id: "_design/badges",
|
|
views: {
|
|
"active-badges": {
|
|
map: `function(doc) {
|
|
if (doc.type === "badge" && doc.isActive) {
|
|
emit(doc.order, doc);
|
|
}
|
|
}`
|
|
}
|
|
}
|
|
},
|
|
{
|
|
_id: "_design/transactions",
|
|
views: {
|
|
"by-user": {
|
|
map: `function(doc) {
|
|
if (doc.type === "point_transaction" && doc.user && doc.user.userId) {
|
|
emit(doc.user.userId, doc);
|
|
}
|
|
}`
|
|
},
|
|
"by-date": {
|
|
map: `function(doc) {
|
|
if (doc.type === "point_transaction" && doc.createdAt) {
|
|
emit(doc.createdAt, doc);
|
|
}
|
|
}`
|
|
}
|
|
}
|
|
},
|
|
{
|
|
_id: "_design/rewards",
|
|
views: {
|
|
"by-cost": {
|
|
map: `function(doc) {
|
|
if (doc.type === "reward" && doc.cost) {
|
|
emit(doc.cost, doc);
|
|
}
|
|
}`
|
|
},
|
|
"by-premium": {
|
|
map: `function(doc) {
|
|
if (doc.type === "reward" && doc.isPremium) {
|
|
emit(doc.isPremium, doc);
|
|
}
|
|
}`
|
|
}
|
|
},
|
|
indexes: {
|
|
"rewards-by-cost": {
|
|
index: { fields: ["type", "cost"] },
|
|
name: "rewards-by-cost",
|
|
type: "json"
|
|
},
|
|
"rewards-by-premium": {
|
|
index: { fields: ["type", "isPremium"] },
|
|
name: "rewards-by-premium",
|
|
type: "json"
|
|
}
|
|
}
|
|
},
|
|
{
|
|
_id: "_design/general",
|
|
indexes: {
|
|
"by-type": {
|
|
index: { fields: ["type"] },
|
|
name: "by-type",
|
|
type: "json"
|
|
},
|
|
"by-user": {
|
|
index: { fields: ["type", "user.userId"] },
|
|
name: "by-user",
|
|
type: "json"
|
|
},
|
|
"by-created-date": {
|
|
index: { fields: ["type", "createdAt"] },
|
|
name: "by-created-date",
|
|
type: "json"
|
|
}
|
|
}
|
|
}
|
|
];
|
|
|
|
for (const designDoc of designDocs) {
|
|
try {
|
|
// Check if design document exists
|
|
const existing = await this.db.get(designDoc._id);
|
|
|
|
// Update with new revision
|
|
designDoc._rev = existing._rev;
|
|
await this.db.insert(designDoc);
|
|
console.log(`Updated design document: ${designDoc._id}`);
|
|
} catch (error) {
|
|
if (error.statusCode === 404) {
|
|
// Create new design document
|
|
await this.db.insert(designDoc);
|
|
console.log(`Created design document: ${designDoc._id}`);
|
|
} else {
|
|
console.error(`Error creating design document ${designDoc._id}:`, error.message);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get database instance
|
|
*/
|
|
getDB() {
|
|
if (!this.isConnected) {
|
|
throw new Error("CouchDB not connected. Call initialize() first.");
|
|
}
|
|
return this.db;
|
|
}
|
|
|
|
/**
|
|
* Check connection status
|
|
*/
|
|
isReady() {
|
|
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();
|
|
|
|
try {
|
|
const response = await this.db.insert(doc);
|
|
return { ...doc, _id: response.id, _rev: response.rev };
|
|
} catch (error) {
|
|
console.error("Error creating document:", error.message);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async getDocument(id, options = {}) {
|
|
if (!this.isConnected) await this.initialize();
|
|
|
|
try {
|
|
const doc = await this.db.get(id, options);
|
|
return doc;
|
|
} catch (error) {
|
|
if (error.statusCode === 404) {
|
|
return null;
|
|
}
|
|
console.error("Error getting document:", error.message);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async updateDocument(doc) {
|
|
if (!this.isConnected) await this.initialize();
|
|
|
|
try {
|
|
if (!doc._id || !doc._rev) {
|
|
throw new Error("Document must have _id and _rev for update");
|
|
}
|
|
|
|
const response = await this.db.insert(doc);
|
|
return { ...doc, _rev: response.rev };
|
|
} catch (error) {
|
|
console.error("Error updating document:", error.message);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async deleteDocument(id, rev) {
|
|
if (!this.isConnected) await this.initialize();
|
|
|
|
try {
|
|
const response = await this.db.destroy(id, rev);
|
|
return response;
|
|
} catch (error) {
|
|
console.error("Error deleting document:", error.message);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// Query operations
|
|
async find(query) {
|
|
if (!this.isConnected) await this.initialize();
|
|
|
|
try {
|
|
const response = await this.db.find(query);
|
|
return response.docs;
|
|
} catch (error) {
|
|
console.error("Error executing query:", error.message);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async findOne(selector) {
|
|
const query = {
|
|
selector,
|
|
limit: 1
|
|
};
|
|
const docs = await this.find(query);
|
|
return docs[0] || null;
|
|
}
|
|
|
|
async findByType(type, selector = {}, options = {}) {
|
|
const query = {
|
|
selector: { type, ...selector },
|
|
...options
|
|
};
|
|
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();
|
|
|
|
try {
|
|
const response = await this.db.view(designDoc, viewName, params);
|
|
return response.rows.map(row => row.value);
|
|
} catch (error) {
|
|
console.error("Error querying view:", error.message);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// Batch operation helper
|
|
async bulkDocs(docs) {
|
|
if (!this.isConnected) await this.initialize();
|
|
|
|
try {
|
|
const response = await this.db.bulk({ docs });
|
|
return response;
|
|
} catch (error) {
|
|
console.error("Error in bulk operation:", error.message);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// Conflict resolution helper
|
|
async resolveConflict(id, conflictResolver) {
|
|
if (!this.isConnected) await this.initialize();
|
|
|
|
try {
|
|
const doc = await this.db.get(id, { open_revs: "all" });
|
|
|
|
if (!Array.isArray(doc) || doc.length === 1) {
|
|
return doc[0] || doc; // No conflict
|
|
}
|
|
|
|
// Multiple revisions exist - resolve conflict
|
|
const resolvedDoc = await conflictResolver(doc);
|
|
return await this.updateDocument(resolvedDoc);
|
|
} catch (error) {
|
|
console.error("Error resolving conflict:", error.message);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// Migration utilities
|
|
async migrateDocument(mongoDoc, transformFn) {
|
|
try {
|
|
const couchDoc = transformFn(mongoDoc);
|
|
return await this.createDocument(couchDoc);
|
|
} catch (error) {
|
|
console.error("Error migrating document:", error.message);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// Validate document structure
|
|
validateDocument(doc, requiredFields = []) {
|
|
const errors = [];
|
|
|
|
if (!doc.type) {
|
|
errors.push("Document must have a 'type' field");
|
|
}
|
|
|
|
for (const field of requiredFields) {
|
|
if (!doc[field]) {
|
|
errors.push(`Document must have '${field}' field`);
|
|
}
|
|
}
|
|
|
|
return errors;
|
|
}
|
|
|
|
// Generate document ID with prefix
|
|
generateId(type, originalId) {
|
|
return `${type}_${originalId}`;
|
|
}
|
|
|
|
// Extract original ID from prefixed ID
|
|
extractOriginalId(prefixedId) {
|
|
const parts = prefixedId.split("_");
|
|
return parts.length > 1 ? parts.slice(1).join("_") : prefixedId;
|
|
}
|
|
|
|
// Graceful shutdown
|
|
async shutdown() {
|
|
try {
|
|
if (this.connection) {
|
|
// Nano doesn't have an explicit close method, but we can mark as disconnected
|
|
this.isConnected = false;
|
|
console.log("CouchDB service shut down gracefully");
|
|
}
|
|
} catch (error) {
|
|
console.error("Error during shutdown:", error.message);
|
|
}
|
|
}
|
|
|
|
// Legacy compatibility methods
|
|
async create(document) {
|
|
return await this.createDocument(document);
|
|
}
|
|
|
|
async getById(id) {
|
|
return await this.getDocument(id);
|
|
}
|
|
|
|
async update(id, document) {
|
|
const existing = await this.getDocument(id);
|
|
if (!existing) {
|
|
throw new Error("Document not found for update");
|
|
}
|
|
const updatedDoc = { ...document, _id: id, _rev: existing._rev };
|
|
return await this.updateDocument(updatedDoc);
|
|
}
|
|
|
|
async delete(id) {
|
|
const doc = await this.getDocument(id);
|
|
if (!doc) return false;
|
|
await this.deleteDocument(id, doc._rev);
|
|
return true;
|
|
}
|
|
|
|
// 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) {
|
|
try {
|
|
// For CouchDB, we need to handle geospatial queries differently
|
|
// We'll use a bounding box approach with the geo index
|
|
const [sw, ne] = bounds;
|
|
|
|
const query = {
|
|
selector: {
|
|
type: 'street',
|
|
status: 'available',
|
|
location: {
|
|
$exists: true
|
|
}
|
|
},
|
|
limit: 1000 // Reasonable limit for geographic queries
|
|
};
|
|
|
|
const streets = await this.find(query);
|
|
|
|
// Filter by bounding box manually (since CouchDB doesn't support $geoWithin in Mango)
|
|
return streets.filter(street => {
|
|
if (!street.location || !street.location.coordinates) {
|
|
return false;
|
|
}
|
|
|
|
const [lng, lat] = street.location.coordinates;
|
|
return lng >= sw[0] && lng <= ne[0] && lat >= sw[1] && lat <= ne[1];
|
|
});
|
|
} catch (error) {
|
|
console.error("Error finding streets by location:", error.message);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
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(); |