- Create comprehensive error infrastructure in backend/utils/modelErrors.js - Implement consistent error handling patterns across all 11 models - Add proper try-catch blocks, validation, and error logging - Standardize error messages and error types - Maintain 100% test compatibility (221/221 tests passing) - Update UserBadge.js with flexible validation for different use cases - Add comprehensive field validation to PointTransaction.js - Improve constructor validation in Street.js and Task.js - Enhance error handling in Badge.js with backward compatibility Models updated: - User.js, Post.js, Report.js (Phase 1) - Event.js, Reward.js, Comment.js (Phase 2) - Street.js, Task.js, Badge.js, PointTransaction.js, UserBadge.js (Phase 3) 🤖 Generated with [AI Assistant] Co-Authored-By: AI Assistant <noreply@ai-assistant.com>
225 lines
7.9 KiB
JavaScript
225 lines
7.9 KiB
JavaScript
const couchdbService = require("../services/couchdbService");
|
|
const {
|
|
ValidationError,
|
|
NotFoundError,
|
|
DatabaseError,
|
|
withErrorHandling,
|
|
createErrorContext
|
|
} = require("../utils/modelErrors");
|
|
|
|
class PointTransaction {
|
|
constructor(transactionData) {
|
|
this.validate(transactionData);
|
|
Object.assign(this, transactionData);
|
|
}
|
|
|
|
validate(transactionData) {
|
|
// Validate required fields
|
|
if (!transactionData.user || transactionData.user.trim() === '') {
|
|
throw new ValidationError('User field is required', 'user', transactionData.user);
|
|
}
|
|
if (transactionData.amount === undefined || transactionData.amount === null) {
|
|
throw new ValidationError('Amount field is required', 'amount', transactionData.amount);
|
|
}
|
|
if (!transactionData.transactionType || transactionData.transactionType.trim() === '') {
|
|
throw new ValidationError('Transaction type field is required', 'transactionType', transactionData.transactionType);
|
|
}
|
|
if (!transactionData.description || transactionData.description.trim() === '') {
|
|
throw new ValidationError('Description field is required', 'description', transactionData.description);
|
|
}
|
|
|
|
// Validate transaction type
|
|
const validTypes = ['earned', 'spent', 'bonus', 'penalty', 'refund'];
|
|
if (!validTypes.includes(transactionData.transactionType)) {
|
|
throw new ValidationError(`Transaction type must be one of: ${validTypes.join(', ')}`, 'transactionType', transactionData.transactionType);
|
|
}
|
|
|
|
// Validate amount based on transaction type
|
|
if (transactionData.transactionType === 'earned' && transactionData.amount <= 0) {
|
|
throw new ValidationError('Earned transactions must have positive amount', 'amount', transactionData.amount);
|
|
}
|
|
if (transactionData.transactionType === 'spent' && transactionData.amount >= 0) {
|
|
throw new ValidationError('Spent transactions must have negative amount', 'amount', transactionData.amount);
|
|
}
|
|
if (transactionData.transactionType === 'bonus' && transactionData.amount <= 0) {
|
|
throw new ValidationError('Bonus transactions must have positive amount', 'amount', transactionData.amount);
|
|
}
|
|
if (transactionData.transactionType === 'penalty' && transactionData.amount >= 0) {
|
|
throw new ValidationError('Penalty transactions must have negative amount', 'amount', transactionData.amount);
|
|
}
|
|
|
|
// Validate source type if provided
|
|
if (transactionData.relatedEntity && transactionData.relatedEntity.type) {
|
|
const validSourceTypes = ['task_completion', 'street_adoption', 'event_participation', 'reward_redemption', 'badge_earned', 'manual_adjustment', 'system_bonus'];
|
|
if (!validSourceTypes.includes(transactionData.relatedEntity.type)) {
|
|
throw new ValidationError(`Source type must be one of: ${validSourceTypes.join(', ')}`, 'relatedEntity.type', transactionData.relatedEntity.type);
|
|
}
|
|
}
|
|
}
|
|
|
|
static async create(transactionData) {
|
|
const errorContext = createErrorContext('PointTransaction', 'create', {
|
|
user: transactionData.user,
|
|
amount: transactionData.amount,
|
|
transactionType: transactionData.transactionType
|
|
});
|
|
|
|
return await withErrorHandling(async () => {
|
|
// Validate using constructor
|
|
new PointTransaction(transactionData);
|
|
|
|
const transaction = {
|
|
_id: `point_transaction_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
|
type: 'point_transaction',
|
|
user: transactionData.user,
|
|
amount: transactionData.amount,
|
|
transactionType: transactionData.transactionType,
|
|
description: transactionData.description.trim(),
|
|
relatedEntity: transactionData.relatedEntity || null,
|
|
balanceAfter: transactionData.balanceAfter,
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString()
|
|
};
|
|
|
|
const result = await couchdbService.createDocument(transaction);
|
|
return { ...transaction, _rev: result.rev };
|
|
}, errorContext);
|
|
}
|
|
|
|
static async findByUser(userId, limit = 50, skip = 0) {
|
|
const errorContext = createErrorContext('PointTransaction', 'findByUser', { userId, limit, skip });
|
|
|
|
return await withErrorHandling(async () => {
|
|
const result = await couchdbService.find({
|
|
selector: {
|
|
type: 'point_transaction',
|
|
user: userId
|
|
},
|
|
sort: [{ createdAt: 'desc' }],
|
|
limit: limit,
|
|
skip: skip
|
|
});
|
|
return result.docs;
|
|
}, errorContext);
|
|
}
|
|
|
|
static async findByType(transactionType, limit = 50, skip = 0) {
|
|
const errorContext = createErrorContext('PointTransaction', 'findByType', { transactionType, limit, skip });
|
|
|
|
return await withErrorHandling(async () => {
|
|
const result = await couchdbService.find({
|
|
selector: {
|
|
type: 'point_transaction',
|
|
transactionType: transactionType
|
|
},
|
|
sort: [{ createdAt: 'desc' }],
|
|
limit: limit,
|
|
skip: skip
|
|
});
|
|
return result.docs;
|
|
}, errorContext);
|
|
}
|
|
|
|
static async findById(id) {
|
|
const errorContext = createErrorContext('PointTransaction', 'findById', { transactionId: id });
|
|
|
|
return await withErrorHandling(async () => {
|
|
try {
|
|
const transaction = await couchdbService.getDocument(id);
|
|
if (!transaction || transaction.type !== 'point_transaction') {
|
|
return null;
|
|
}
|
|
return transaction;
|
|
} catch (error) {
|
|
if (error.statusCode === 404) {
|
|
return null;
|
|
}
|
|
throw error;
|
|
}
|
|
}, errorContext);
|
|
}
|
|
|
|
static async getUserBalance(userId) {
|
|
const errorContext = createErrorContext('PointTransaction', 'getUserBalance', { userId });
|
|
|
|
return await withErrorHandling(async () => {
|
|
// Get the most recent transaction for the user to find current balance
|
|
const result = await couchdbService.find({
|
|
selector: {
|
|
type: 'point_transaction',
|
|
user: userId
|
|
},
|
|
sort: [{ createdAt: 'desc' }],
|
|
limit: 1
|
|
});
|
|
|
|
if (result.docs.length === 0) {
|
|
return 0;
|
|
}
|
|
|
|
return result.docs[0].balanceAfter;
|
|
}, errorContext);
|
|
}
|
|
|
|
static async getUserTransactionHistory(userId, startDate, endDate) {
|
|
const errorContext = createErrorContext('PointTransaction', 'getUserTransactionHistory', { userId, startDate, endDate });
|
|
|
|
return await withErrorHandling(async () => {
|
|
const selector = {
|
|
type: 'point_transaction',
|
|
user: userId
|
|
};
|
|
|
|
if (startDate || endDate) {
|
|
selector.createdAt = {};
|
|
if (startDate) {
|
|
selector.createdAt.$gte = startDate;
|
|
}
|
|
if (endDate) {
|
|
selector.createdAt.$lte = endDate;
|
|
}
|
|
}
|
|
|
|
const result = await couchdbService.find({
|
|
selector: selector,
|
|
sort: [{ createdAt: 'desc' }]
|
|
});
|
|
|
|
return result.docs;
|
|
}, errorContext);
|
|
}
|
|
|
|
static async getTransactionStats(userId, startDate, endDate) {
|
|
const errorContext = createErrorContext('PointTransaction', 'getTransactionStats', { userId, startDate, endDate });
|
|
|
|
return await withErrorHandling(async () => {
|
|
const transactions = await this.getUserTransactionHistory(userId, startDate, endDate);
|
|
|
|
const stats = {
|
|
totalEarned: 0,
|
|
totalSpent: 0,
|
|
transactionCount: transactions.length,
|
|
transactionBreakdown: {}
|
|
};
|
|
|
|
transactions.forEach(transaction => {
|
|
if (transaction.amount > 0) {
|
|
stats.totalEarned += transaction.amount;
|
|
} else {
|
|
stats.totalSpent += Math.abs(transaction.amount);
|
|
}
|
|
|
|
const type = transaction.transactionType;
|
|
if (!stats.transactionBreakdown[type]) {
|
|
stats.transactionBreakdown[type] = { count: 0, total: 0 };
|
|
}
|
|
stats.transactionBreakdown[type].count++;
|
|
stats.transactionBreakdown[type].total += transaction.amount;
|
|
});
|
|
|
|
return stats;
|
|
}, errorContext);
|
|
}
|
|
}
|
|
|
|
module.exports = PointTransaction; |