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>
This commit is contained in:
596
backend/services/couchdbService.js
Normal file
596
backend/services/couchdbService.js
Normal file
@@ -0,0 +1,596 @@
|
||||
const Nano = require('nano');
|
||||
|
||||
class CouchDBService {
|
||||
constructor() {
|
||||
this.nano = Nano(process.env.COUCHDB_URL || 'http://localhost:5984');
|
||||
this.dbName = process.env.COUCHDB_DB || 'adopt-a-street';
|
||||
this.db = null;
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
try {
|
||||
this.db = this.nano.db.use(this.dbName);
|
||||
} catch (error) {
|
||||
if (error.statusCode === 404) {
|
||||
await this.nano.db.create(this.dbName);
|
||||
this.db = this.nano.db.use(this.dbName);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Generic CRUD operations
|
||||
async create(document) {
|
||||
if (!this.db) await this.initialize();
|
||||
|
||||
try {
|
||||
const result = await this.db.insert(document);
|
||||
return { ...document, _id: result.id, _rev: result.rev };
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to create document: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async getById(id) {
|
||||
if (!this.db) await this.initialize();
|
||||
|
||||
try {
|
||||
return await this.db.get(id);
|
||||
} catch (error) {
|
||||
if (error.statusCode === 404) return null;
|
||||
throw new Error(`Failed to get document: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async update(id, document) {
|
||||
if (!this.db) await this.initialize();
|
||||
|
||||
try {
|
||||
const existing = await this.db.get(id);
|
||||
const updatedDoc = { ...document, _id: id, _rev: existing._rev };
|
||||
const result = await this.db.insert(updatedDoc);
|
||||
return { ...updatedDoc, _rev: result.rev };
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to update document: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async delete(id) {
|
||||
if (!this.db) await this.initialize();
|
||||
|
||||
try {
|
||||
const doc = await this.db.get(id);
|
||||
await this.db.destroy(id, doc._rev);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error.statusCode === 404) return false;
|
||||
throw new Error(`Failed to delete document: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Query operations
|
||||
async find(selector, options = {}) {
|
||||
if (!this.db) await this.initialize();
|
||||
|
||||
try {
|
||||
const query = { selector, ...options };
|
||||
const result = await this.db.find(query);
|
||||
return result.docs;
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to find documents: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async findOne(selector) {
|
||||
const docs = await this.find(selector, { limit: 1 });
|
||||
return docs.length > 0 ? docs[0] : null;
|
||||
}
|
||||
|
||||
async findByType(type, selector = {}, options = {}) {
|
||||
return this.find({ type, ...selector }, options);
|
||||
}
|
||||
|
||||
// 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) {
|
||||
return this.find({
|
||||
type: 'street',
|
||||
status: 'available',
|
||||
location: {
|
||||
$geoWithin: {
|
||||
$box: bounds
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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();
|
||||
Reference in New Issue
Block a user