From ce414cc4cc154c79e88d342c59ecf768640a1ca0 Mon Sep 17 00:00:00 2001 From: navin-oss Date: Fri, 6 Mar 2026 23:11:07 +0530 Subject: [PATCH] refactor: Extract server.js logic into dedicated services --- backend/server.js | 356 ++++++------------------ backend/services/batchService.js | 354 +++++++++++++++++++++++ backend/services/blockchainService.js | 227 +++++++++++++++ backend/services/notificationService.js | 198 +++++++++++++ 4 files changed, 871 insertions(+), 264 deletions(-) create mode 100644 backend/services/batchService.js create mode 100644 backend/services/blockchainService.js create mode 100644 backend/services/notificationService.js diff --git a/backend/server.js b/backend/server.js index bb7dc39..46bbebb 100644 --- a/backend/server.js +++ b/backend/server.js @@ -7,7 +7,6 @@ const mongoSanitize = require('express-mongo-sanitize'); const jwt = require('jsonwebtoken'); const mongoose = require('mongoose'); // Required for transactions const { ethers } = require('ethers'); -const QRCode = require('qrcode'); const swaggerUi = require('swagger-ui-express'); const swaggerSpec = require('./swagger'); const connectDB = require('./config/db'); @@ -20,14 +19,14 @@ const errorHandlerMiddleware = require('./middleware/errorHandler'); const { createBatchSchema, updateBatchSchema } = require("./validations/batchSchema"); const { protect, adminOnly, authorizeBatchOwner, authorizeRoles } = require('./middleware/auth'); const apiResponse = require('./utils/apiResponse'); -const crypto = require('crypto'); + +// Import Services +const blockchainService = require('./services/blockchainService'); +const batchService = require('./services/batchService'); +const notificationService = require('./services/notificationService'); // Import MongoDB Model const Batch = require('./models/Batch'); -const Counter = require('./models/Counter'); - -// Connect to Database -connectDB(); const app = express(); const PORT = process.env.PORT || 3001; @@ -58,6 +57,11 @@ const securityLogger = (req, res, next) => { suspiciousPatterns.forEach(pattern => { if (pattern.test(requestString)) { console.warn(`[SECURITY WARNING] Suspicious pattern detected from IP ${ip}: ${pattern}`); + notificationService.notifySecurityEvent('suspicious_pattern', { + ip, + pattern: pattern.toString(), + path: req.path + }); } }); @@ -170,6 +174,17 @@ app.use(express.urlencoded({ extended: true, limit: maxFileSize })); app.use(mongoSanitize()); app.use(securityLogger); +// ==================== BLOCKCHAIN SERVICE INITIALIZATION ==================== + +// Validate blockchain environment +if (process.env.NODE_ENV !== 'test') { + try { + blockchainService.validateEnvironment(); + } catch (error) { + console.error('Blockchain configuration error:', error.message); + } +} + // ==================== ROUTES ==================== // Mount health check main router @@ -181,128 +196,6 @@ app.use('/api/docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec, { customSiteTitle: 'CropChain API Documentation' })); -// Blockchain configuration -const REQUIRED_ENV_VARS = [ - 'INFURA_URL', - 'CONTRACT_ADDRESS', - 'PRIVATE_KEY' -]; - -if (process.env.NODE_ENV !== 'test') { - REQUIRED_ENV_VARS.forEach((key) => { - if (!process.env[key]) { - throw new Error(`Missing required environment variable: ${key}`); - } - }); - - if (!/^0x[a-fA-F0-9]{64}$/.test(process.env.PRIVATE_KEY)) { - throw new Error('Invalid PRIVATE_KEY format'); - } -} - -const PROVIDER_URL = process.env.INFURA_URL; -const CONTRACT_ADDRESS = process.env.CONTRACT_ADDRESS; -const PRIVATE_KEY = process.env.PRIVATE_KEY; - -// Initialize blockchain provider and contract (reused for listener) -let provider; -let contractInstance; -let wallet; - -if (PROVIDER_URL && CONTRACT_ADDRESS && PRIVATE_KEY) { - try { - provider = new ethers.JsonRpcProvider(PROVIDER_URL); - wallet = new ethers.Wallet(PRIVATE_KEY, provider); - - const contractABI = [ - "event BatchCreated(bytes32 indexed batchId, string ipfsCID, uint256 quantity, address indexed creator)", - "event BatchUpdated(bytes32 indexed batchId, uint8 stage, string actorName, string location, address indexed updatedBy)", - "function getBatch(bytes32 batchId) view returns (tuple(bytes32 batchId, bytes32 cropTypeHash, string ipfsCID, uint256 quantity, uint256 createdAt, address creator, bool exists, bool isRecalled))", - "function createBatch(bytes32 batchId, bytes32 cropTypeHash, string calldata ipfsCID, uint256 quantity, string calldata actorName, string calldata location, string calldata notes) returns (bool)", - "function updateBatch(bytes32 batchId, uint8 stage, string calldata actorName, string calldata location, string calldata notes) returns (bool)" - ]; - - contractInstance = new ethers.Contract(CONTRACT_ADDRESS, contractABI, wallet); - console.log('āœ“ Blockchain contract instance initialized'); - } catch (error) { - console.error('Failed to initialize blockchain connection:', error.message); - contractInstance = null; - } -} else { - console.log('ā„¹ļø Blockchain not configured - running without contract instance'); -} - -// Helper functions -async function generateBatchId() { - const session = await mongoose.startSession(); - session.startTransaction(); - - try { - const counter = await Counter.findOneAndUpdate( - { name: 'batchId' }, - { $inc: { seq: 1 } }, - { new: true, upsert: true, session } - ); - - const currentYear = new Date().getFullYear(); - const batchId = `CROP-${currentYear}-${String(counter.seq).padStart(3, '0')}`; - - await session.commitTransaction(); - session.endSession(); - - return batchId; - - } catch (error) { - await session.abortTransaction(); - session.endSession(); - throw error; - } -} - -/** - * Generate batch ID with optional session support for transaction safety - * @param {mongoose.ClientSession} session - MongoDB session for transaction - * @returns {string} - Generated batch ID - */ -async function generateBatchId(session = null) { - const currentYear = new Date().getFullYear(); - const options = { new: true, upsert: true }; - if (session) { - options.session = session; - } - - const counter = await Counter.findOneAndUpdate( - { name: 'batchId' }, - { $inc: { seq: 1 } }, - options - ); - return `CROP-${currentYear}-${String(counter.seq).padStart(4, '0')}`; -} - - -async function generateQRCode(batchId) { - try { - return await QRCode.toDataURL(batchId, { - width: 200, - margin: 2, - color: { - dark: '#22c55e', - light: '#ffffff' - } - }); - } catch (error) { - console.error('Failed to generate QR code:', error); - return ''; - } -} - -function simulateBlockchainHash(data) { - return '0x' + crypto - .createHash('sha256') - .update(JSON.stringify(data) + Date.now().toString()) - .digest('hex'); -} - // Import Routes const authRoutes = require('./routes/authRoutes'); const verificationRoutes = require('./routes/verification'); @@ -313,62 +206,39 @@ app.use('/api/auth', authLimiter, authRoutes); // Mount Verification Routes app.use('/api/verification', generalLimiter, verificationRoutes); -// Batch routes - ALL USING MONGODB ONLY +// ==================== BATCH ROUTES (USING BATCH SERVICE) ==================== // CREATE batch - requires authentication // Uses MongoDB transaction to prevent race conditions in batch ID generation (CVSS 7.5 fix) app.post('/api/batches', batchLimiter, protect, validateRequest(createBatchSchema), async (req, res) => { - const session = await mongoose.startSession(); - session.startTransaction(); - try { const validatedData = req.body; - // Generate batch ID within transaction for atomicity - const batchId = await generateBatchId(session); - const qrCode = await generateQRCode(batchId); - - const batch = await Batch.create([{ - batchId, - farmerId: req.user.farmerId || req.user.id, // Use authenticated user's ID - farmerName: validatedData.farmerName || req.user.name, - farmerAddress: validatedData.farmerAddress || req.user.address || '', - cropType: validatedData.cropType, - quantity: validatedData.quantity, - harvestDate: validatedData.harvestDate, - origin: validatedData.origin, - certifications: validatedData.certifications, - description: validatedData.description, - currentStage: "farmer", - isRecalled: false, - qrCode, - blockchainHash: simulateBlockchainHash(validatedData), - syncStatus: 'pending', - updates: [{ - stage: "farmer", - actor: validatedData.farmerName || req.user.name, - location: validatedData.origin, - timestamp: validatedData.harvestDate, - notes: validatedData.description || "Initial harvest recorded" - }] - }], { session }); - - // Commit the transaction - await session.commitTransaction(); - session.endSession(); - - console.log(`[SUCCESS] Batch created: ${batchId} by user ${req.user.id} (${req.user.email}) from IP: ${req.ip}`); + const result = await batchService.createBatch(validatedData, req.user); + + console.log(`[SUCCESS] Batch created: ${result.batch.batchId} by user ${req.user.id} (${req.user.email}) from IP: ${req.ip}`); + + // Notify about batch creation + notificationService.notifyBatchCreated(result.batch.batchId, req.user); const response = apiResponse.successResponse( - { batch: batch[0] }, + { batch: result.batch }, 'Batch created successfully', 201 ); res.status(201).json(response); } catch (error) { - // Abort transaction on error - await session.abortTransaction(); - session.endSession(); + // Handle duplicate key error specifically + if (error.code === 11000) { + const response = apiResponse.errorResponse( + 'Batch with this ID already exists', + 'DUPLICATE_BATCH_ERROR', + 409 + ); + return res.status(409).json(response); + } + + notificationService.notifyError('batch creation', error); console.error('Error creating batch:', error); const response = apiResponse.errorResponse( @@ -384,21 +254,19 @@ app.post('/api/batches', batchLimiter, protect, validateRequest(createBatchSchem app.get('/api/batches/:batchId', batchLimiter, async (req, res) => { try { const { batchId } = req.params; - const batch = await Batch.findOne({ batchId }); + + const result = await batchService.getBatch(batchId); - if (!batch) { + if (!result.success) { console.log(`[NOT FOUND] Batch lookup failed: ${batchId} from IP: ${req.ip}`); const response = apiResponse.notFoundResponse('Batch', `ID: ${batchId}`); - return res.status(404).json(response); - } - - if (batch.isRecalled) { - console.log("🚨 ALERT: Recalled batch viewed:", batchId); + return res.status(result.statusCode).json(response); } - const response = apiResponse.successResponse({ batch }, 'Batch retrieved successfully'); + const response = apiResponse.successResponse({ batch: result.batch }, 'Batch retrieved successfully'); res.json(response); } catch (error) { + notificationService.notifyError('batch fetch', error); console.error('Error fetching batch:', error); const response = apiResponse.errorResponse( 'Failed to fetch batch', @@ -415,39 +283,25 @@ app.put('/api/batches/:batchId', batchLimiter, protect, authorizeBatchOwner, val const { batchId } = req.params; const validatedData = req.body; - // Normalize stage to lowercase for consistency - const normalizedStage = validatedData.stage.toLowerCase(); + const result = await batchService.updateBatch(batchId, validatedData, req.user); - // Note: authorizeBatchOwner middleware already checks if batch exists - // and verifies ownership, so we can proceed directly to update - - const update = { - stage: normalizedStage, - actor: validatedData.actor, - location: validatedData.location, - timestamp: validatedData.timestamp, - notes: validatedData.notes - }; + if (!result.success) { + const response = apiResponse.notFoundResponse('Batch', `ID: ${batchId}`); + return res.status(result.statusCode || 404).json(response); + } - const batch = await Batch.findOneAndUpdate( - { batchId }, - { - $push: { updates: update }, - currentStage: normalizedStage, - blockchainHash: simulateBlockchainHash(update), - syncStatus: 'pending' - }, - { new: true } - ); + console.log(`[SUCCESS] Batch updated: ${batchId} to stage ${validatedData.stage} by ${validatedData.actor} from IP: ${req.ip}`); - console.log(`[SUCCESS] Batch updated: ${batchId} to stage ${normalizedStage} by ${validatedData.actor} from IP: ${req.ip}`); + // Notify about batch update + notificationService.notifyBatchUpdated(batchId, validatedData.stage, req.user); const response = apiResponse.successResponse( - { batch }, + { batch: result.batch }, 'Batch updated successfully' ); res.json(response); } catch (error) { + notificationService.notifyError('batch update', error); console.error('Error updating batch:', error); const response = apiResponse.errorResponse( 'Failed to update batch', @@ -469,29 +323,21 @@ app.post( try { const { batchId } = req.params; - const batch = await Batch.findOne({ batchId }); - - if (!batch) { - return res.status(404).json({ error: 'Batch not found' }); - } + const result = await batchService.recallBatch(batchId, req.user); - if (batch.isRecalled) { - return res.status(400).json({ error: 'Batch already recalled' }); + if (!result.success) { + return res.status(result.statusCode).json({ error: result.error }); } - batch.isRecalled = true; - await batch.save(); - - console.log(`🚨 RECALL by admin ${req.user?.email || 'unknown'} for batch ${batchId}`); - res.json({ success: true, - message: 'Batch recalled successfully', - recalledBy: req.user?.email, - recalledAt: new Date().toISOString(), - batch + message: result.message, + recalledBy: result.recalledBy, + recalledAt: result.recalledAt, + batch: result.batch }); } catch (error) { + notificationService.notifyError('batch recall', error); console.error('Error recalling batch:', error); res.status(500).json({ error: 'Failed to recall batch' }); } @@ -501,30 +347,17 @@ app.post( // GET all batches app.get('/api/batches', batchLimiter, async (req, res) => { try { - const allBatches = await Batch.find().sort({ createdAt: -1 }); - - const uniqueFarmers = new Set(allBatches.map(b => b.farmerName)).size; - const totalQuantity = allBatches.reduce((sum, batch) => sum + batch.quantity, 0); - - const stats = { - totalBatches: allBatches.length, - totalFarmers: uniqueFarmers, - totalQuantity, - recentBatches: allBatches.filter(batch => { - const monthAgo = new Date(); - monthAgo.setDate(monthAgo.getDate() - 30); - return new Date(batch.createdAt) > monthAgo; - }).length - }; + const result = await batchService.getAllBatches(); console.log(`[SUCCESS] Batches list retrieved from IP: ${req.ip}`); const response = apiResponse.successResponse( - { stats, batches: allBatches }, + { stats: result.stats, batches: result.batches }, 'Batches retrieved successfully' ); res.json(response); } catch (error) { + notificationService.notifyError('batches fetch', error); console.error('Error fetching batches:', error); const response = apiResponse.errorResponse( 'Failed to fetch batches', @@ -535,35 +368,21 @@ app.get('/api/batches', batchLimiter, async (req, res) => { } }); -// AI Service - MongoDB only +// ==================== AI SERVICE ==================== + +// Create batch service interface for AI service const batchServiceForAI = { async getBatch(batchId) { - return await Batch.findOne({ batchId }); + const result = await batchService.getBatch(batchId); + return result.success ? result.batch : null; }, async getDashboardStats() { - const allBatches = await Batch.find(); - const uniqueFarmers = new Set(allBatches.map(b => b.farmerName)).size; - const totalQuantity = allBatches.reduce((sum, batch) => sum + batch.quantity, 0); - - return { - stats: { - totalBatches: allBatches.length, - totalFarmers: uniqueFarmers, - totalQuantity, - recentBatches: allBatches.filter(batch => { - const monthAgo = new Date(); - monthAgo.setDate(monthAgo.getDate() - 30); - return new Date(batch.createdAt) > monthAgo; - }).length - } - }; + return await batchService.getDashboardStats(); } }; -// AI Service import (ADD THIS if missing) -// AI Service import (Already imported at initialization) - +// AI Chat endpoint app.post('/api/ai/chat', batchLimiter, validateRequest(chatSchema), async (req, res) => { try { const { message } = req.body; @@ -588,6 +407,7 @@ app.post('/api/ai/chat', batchLimiter, validateRequest(chatSchema), async (req, res.json(response); } catch (error) { + notificationService.notifyError('AI chat', error); console.error('AI Chat error:', error); const response = apiResponse.errorResponse( @@ -599,14 +419,18 @@ app.post('/api/ai/chat', batchLimiter, validateRequest(chatSchema), async (req, } }); -// Serve Frontend in Production -if (process.env.NODE_ENV === "production") { - app.use(express.static(path.join(__dirname, "../frontend/build"))); +// ==================== HEALTH CHECK ==================== - app.get("*", (req, res) => { - res.sendFile(path.join(__dirname, "../frontend/build/index.html")); +app.get('/api/health', (req, res) => { + res.json({ + status: 'ok', + timestamp: new Date().toISOString(), + services: { + blockchain: blockchainService.isAvailable() ? 'connected' : 'demo mode', + database: mongoose.connection.readyState === 1 ? 'connected' : 'disconnected' + } }); -} +}); // ==================== ERROR HANDLERS ==================== @@ -622,6 +446,9 @@ app.use(errorHandlerMiddleware); // ==================== SERVER STARTUP ==================== +// Connect to Database +connectDB(); + // Import createAdmin script const createAdmin = require('./scripts/create-admin'); @@ -662,7 +489,7 @@ if (process.env.NODE_ENV !== 'test') { if (!process.env.JWT_SECRET) { console.warn(' āš ļø JWT_SECRET not set - authentication will not work'); } - if (!PROVIDER_URL || !CONTRACT_ADDRESS) { + if (!blockchainService.isAvailable()) { console.warn(' āš ļø Blockchain configuration incomplete - running in demo mode'); } } @@ -670,9 +497,10 @@ if (process.env.NODE_ENV !== 'test') { console.log('\nāœ… Server startup complete\n'); // Start blockchain event listener - if (contractInstance) { + const contract = blockchainService.getContract(); + if (contract) { try { - startListener(contractInstance); + startListener(contract); console.log('šŸ”— Blockchain event listener started'); } catch (error) { console.error('āŒ Failed to start blockchain listener:', error.message); diff --git a/backend/services/batchService.js b/backend/services/batchService.js new file mode 100644 index 0000000..ffa5c90 --- /dev/null +++ b/backend/services/batchService.js @@ -0,0 +1,354 @@ +/** + * BatchService - Handles all batch-related business logic + * Extracted from server.js to follow Separation of Concerns principle + */ + +const mongoose = require('mongoose'); +const QRCode = require('qrcode'); +const Batch = require('../models/Batch'); +const Counter = require('../models/Counter'); +const blockchainService = require('./blockchainService'); +const notificationService = require('./notificationService'); +const apiResponse = require('../utils/apiResponse'); + +class BatchService { + /** + * Generate a unique batch ID with transaction safety + * @param {mongoose.ClientSession} session - MongoDB session for transaction + * @returns {string} - Generated batch ID + */ + async generateBatchId(session = null) { + const currentYear = new Date().getFullYear(); + const options = { new: true, upsert: true }; + + if (session) { + options.session = session; + } + + const counter = await Counter.findOneAndUpdate( + { name: 'batchId' }, + { $inc: { seq: 1 } }, + options + ); + + return `CROP-${currentYear}-${String(counter.seq).padStart(4, '0')}`; + } + + /** + * Generate QR code for a batch + * @param {string} batchId - Batch identifier + * @returns {string} - QR code as data URL + */ + async generateQRCode(batchId) { + try { + return await QRCode.toDataURL(batchId, { + width: 200, + margin: 2, + color: { + dark: '#22c55e', + light: '#ffffff' + } + }); + } catch (error) { + console.error('Failed to generate QR code:', error); + return ''; + } + } + + /** + * Create a new batch with MongoDB transaction + * @param {Object} batchData - Validated batch data from request + * @param {Object} user - Authenticated user object + * @returns {Object} - Result with created batch or error + */ + async createBatch(batchData, user) { + const session = await mongoose.startSession(); + session.startTransaction(); + + try { + // Generate batch ID within transaction for atomicity + const batchId = await this.generateBatchId(session); + const qrCode = await this.generateQRCode(batchId); + + const blockchainHash = blockchainService.simulateHash(batchData); + + const batch = await Batch.create([{ + batchId, + farmerId: user.farmerId || user.id, + farmerName: batchData.farmerName || user.name, + farmerAddress: batchData.farmerAddress || user.address || '', + cropType: batchData.cropType, + quantity: batchData.quantity, + harvestDate: batchData.harvestDate, + origin: batchData.origin, + certifications: batchData.certifications, + description: batchData.description, + currentStage: 'farmer', + isRecalled: false, + qrCode, + blockchainHash, + syncStatus: 'pending', + updates: [{ + stage: 'farmer', + actor: batchData.farmerName || user.name, + location: batchData.origin, + timestamp: batchData.harvestDate, + notes: batchData.description || 'Initial harvest recorded' + }] + }], { session }); + + // Commit the transaction + await session.commitTransaction(); + session.endSession(); + + // Try to sync with blockchain (non-blocking) + this.syncToBlockchain(batch[0], 'create'); + + console.log(`[SUCCESS] Batch created: ${batchId} by user ${user.id} (${user.email})`); + + return { + success: true, + batch: batch[0], + message: 'Batch created successfully' + }; + } catch (error) { + await session.abortTransaction(); + session.endSession(); + + console.error('Error creating batch:', error); + throw error; + } + } + + /** + * Get a batch by ID + * @param {string} batchId - Batch identifier + * @returns {Object} - Result with batch or error + */ + async getBatch(batchId) { + try { + const batch = await Batch.findOne({ batchId }); + + if (!batch) { + return { + success: false, + error: 'Batch not found', + statusCode: 404 + }; + } + + // Alert if batch is recalled + if (batch.isRecalled) { + notificationService.alertRecall(batchId); + } + + return { + success: true, + batch, + message: 'Batch retrieved successfully' + }; + } catch (error) { + console.error('Error fetching batch:', error); + throw error; + } + } + + /** + * Update a batch's stage + * @param {string} batchId - Batch identifier + * @param {Object} updateData - Update data (stage, actor, location, timestamp, notes) + * @param {Object} user - Authenticated user + * @returns {Object} - Result with updated batch or error + */ + async updateBatch(batchId, updateData, user) { + try { + // Normalize stage to lowercase + const normalizedStage = updateData.stage.toLowerCase(); + + const blockchainHash = blockchainService.simulateHash(updateData); + + const batch = await Batch.findOneAndUpdate( + { batchId }, + { + $push: { + updates: { + stage: normalizedStage, + actor: updateData.actor, + location: updateData.location, + timestamp: updateData.timestamp, + notes: updateData.notes + } + }, + currentStage: normalizedStage, + blockchainHash, + syncStatus: 'pending' + }, + { new: true } + ); + + if (!batch) { + return { + success: false, + error: 'Batch not found', + statusCode: 404 + }; + } + + // Try to sync with blockchain (non-blocking) + this.syncToBlockchain(batch, 'update'); + + console.log(`[SUCCESS] Batch updated: ${batchId} to stage ${normalizedStage} by ${updateData.actor}`); + + return { + success: true, + batch, + message: 'Batch updated successfully' + }; + } catch (error) { + console.error('Error updating batch:', error); + throw error; + } + } + + /** + * Recall a batch (admin only) + * @param {string} batchId - Batch identifier + * @param {Object} adminUser - Admin user who initiated recall + * @returns {Object} - Result with recalled batch or error + */ + async recallBatch(batchId, adminUser) { + try { + const batch = await Batch.findOne({ batchId }); + + if (!batch) { + return { + success: false, + error: 'Batch not found', + statusCode: 404 + }; + } + + if (batch.isRecalled) { + return { + success: false, + error: 'Batch already recalled', + statusCode: 400 + }; + } + + batch.isRecalled = true; + await batch.save(); + + // Send recall notification + notificationService.sendRecallNotification(batch, adminUser); + + console.log(`🚨 RECALL by admin ${adminUser.email || 'unknown'} for batch ${batchId}`); + + return { + success: true, + batch, + message: 'Batch recalled successfully', + recalledBy: adminUser.email, + recalledAt: new Date().toISOString() + }; + } catch (error) { + console.error('Error recalling batch:', error); + throw error; + } + } + + /** + * Get all batches with statistics + * @returns {Object} - Result with batches and stats + */ + async getAllBatches() { + try { + const allBatches = await Batch.find().sort({ createdAt: -1 }); + + const stats = this.calculateStats(allBatches); + + return { + success: true, + batches: allBatches, + stats, + message: 'Batches retrieved successfully' + }; + } catch (error) { + console.error('Error fetching batches:', error); + throw error; + } + } + + /** + * Calculate statistics from batch collection + * @param {Array} batches - Array of batch documents + * @returns {Object} - Calculated statistics + */ + calculateStats(batches) { + const uniqueFarmers = new Set(batches.map(b => b.farmerName)).size; + const totalQuantity = batches.reduce((sum, batch) => sum + batch.quantity, 0); + + const monthAgo = new Date(); + monthAgo.setDate(monthAgo.getDate() - 30); + + return { + totalBatches: batches.length, + totalFarmers: uniqueFarmers, + totalQuantity, + recentBatches: batches.filter(batch => new Date(batch.createdAt) > monthAgo).length + }; + } + + /** + * Get dashboard statistics (for AI service) + * @returns {Object} - Dashboard statistics + */ + async getDashboardStats() { + const allBatches = await Batch.find(); + const stats = this.calculateStats(allBatches); + + return { stats }; + } + + /** + * Sync batch to blockchain (non-blocking) + * @param {Object} batch - Batch document + * @param {string} action - 'create' or 'update' + */ + async syncToBlockchain(batch, action) { + if (!blockchainService.isAvailable()) { + return; + } + + try { + if (action === 'create') { + await blockchainService.createBatchOnChain( + batch.batchId, + batch.cropType, + batch.blockchainHash || '', + batch.quantity, + batch.farmerName, + batch.origin, + batch.description || '' + ); + } else if (action === 'update') { + const stageMap = { 'farmer': 0, 'processor': 1, 'distributor': 2, 'retailer': 3, 'consumer': 4 }; + const stageNum = stageMap[batch.currentStage] || 0; + const lastUpdate = batch.updates[batch.updates.length - 1]; + + await blockchainService.updateBatchOnChain( + batch.batchId, + stageNum, + lastUpdate?.actor || '', + lastUpdate?.location || '', + lastUpdate?.notes || '' + ); + } + } catch (error) { + console.error(`Blockchain sync failed for batch ${batch.batchId}:`, error.message); + } + } +} + +// Export singleton instance +module.exports = new BatchService(); diff --git a/backend/services/blockchainService.js b/backend/services/blockchainService.js new file mode 100644 index 0000000..4219614 --- /dev/null +++ b/backend/services/blockchainService.js @@ -0,0 +1,227 @@ +/** + * BlockchainService - Handles all blockchain-related operations + * Extracted from server.js to follow Separation of Concerns principle + */ + +const { ethers } = require('ethers'); +const crypto = require('crypto'); +const blockchainConfig = require('../config/blockchain'); + +class BlockchainService { + constructor() { + this.contract = null; + this.isInitialized = false; + this.initialize(); + } + + /** + * Initialize blockchain connection + */ + initialize() { + try { + this.contract = blockchainConfig.getContract(); + this.isInitialized = this.contract !== null; + + if (this.isInitialized) { + console.log('āœ“ BlockchainService initialized'); + } else { + console.log('ā„¹ļø BlockchainService running in demo mode (no contract)'); + } + } catch (error) { + console.error('Failed to initialize BlockchainService:', error.message); + this.isInitialized = false; + } + } + + /** + * Validate required environment variables + * @throws Error if required variables are missing + */ + validateEnvironment() { + const requiredEnvVars = ['INFURA_URL', 'CONTRACT_ADDRESS', 'PRIVATE_KEY']; + const missing = requiredEnvVars.filter(key => !process.env[key]); + + if (missing.length > 0) { + throw new Error(`Missing required environment variables: ${missing.join(', ')}`); + } + + if (!/^0x[a-fA-F0-9]{64}$/.test(process.env.PRIVATE_KEY)) { + throw new Error('Invalid PRIVATE_KEY format'); + } + } + + /** + * Simulate a blockchain hash for demo/offline mode + * @param {Object} data - Data to hash + * @returns {string} - Simulated blockchain hash + */ + simulateHash(data) { + return '0x' + crypto + .createHash('sha256') + .update(JSON.stringify(data) + Date.now().toString()) + .digest('hex'); + } + + /** + * Create a batch on the blockchain + * @param {string} batchId - Batch identifier + * @param {string} cropType - Type of crop + * @param {string} ipfsCID - IPFS Content Identifier + * @param {number} quantity - Quantity of crop + * @param {string} actorName - Name of the actor + * @param {string} location - Location + * @param {string} notes - Additional notes + * @returns {Object} - Transaction result + */ + async createBatchOnChain(batchId, cropType, ipfsCID, quantity, actorName, location, notes) { + if (!this.isInitialized || !this.contract) { + return { + success: false, + demo: true, + message: 'Blockchain not configured, using local storage only', + hash: this.simulateHash({ batchId, cropType, ipfsCID, quantity }) + }; + } + + try { + const batchIdBytes32 = ethers.id(batchId); + const cropTypeHash = ethers.id(cropType); + + const tx = await this.contract.createBatch( + batchIdBytes32, + cropTypeHash, + ipfsCID, + quantity, + actorName, + location, + notes + ); + + const receipt = await tx.wait(); + + return { + success: true, + transactionHash: receipt.hash, + blockNumber: receipt.blockNumber, + batchId, + message: 'Batch created on blockchain' + }; + } catch (error) { + console.error('Error creating batch on blockchain:', error.message); + return { + success: false, + error: error.message, + message: 'Failed to create batch on blockchain' + }; + } + } + + /** + * Update a batch stage on the blockchain + * @param {string} batchId - Batch identifier + * @param {number} stage - Stage number (0-5) + * @param {string} actorName - Name of the actor + * @param {string} location - Location + * @param {string} notes - Additional notes + * @returns {Object} - Transaction result + */ + async updateBatchOnChain(batchId, stage, actorName, location, notes) { + if (!this.isInitialized || !this.contract) { + return { + success: false, + demo: true, + message: 'Blockchain not configured, using local storage only', + hash: this.simulateHash({ batchId, stage, actorName, location }) + }; + } + + try { + const batchIdBytes32 = ethers.id(batchId); + + const tx = await this.contract.updateBatch( + batchIdBytes32, + stage, + actorName, + location, + notes + ); + + const receipt = await tx.wait(); + + return { + success: true, + transactionHash: receipt.hash, + blockNumber: receipt.blockNumber, + batchId, + message: 'Batch updated on blockchain' + }; + } catch (error) { + console.error('Error updating batch on blockchain:', error.message); + return { + success: false, + error: error.message, + message: 'Failed to update batch on blockchain' + }; + } + } + + /** + * Get batch information from blockchain + * @param {string} batchId - Batch identifier + * @returns {Object} - Batch data from blockchain + */ + async getBatchFromChain(batchId) { + if (!this.isInitialized || !this.contract) { + return { + success: false, + demo: true, + message: 'Blockchain not configured' + }; + } + + try { + const batchIdBytes32 = ethers.id(batchId); + const batch = await this.contract.getBatch(batchIdBytes32); + + return { + success: true, + batch: { + batchId: batch.batchId, + cropTypeHash: batch.cropTypeHash, + ipfsCID: batch.ipfsCID, + quantity: batch.quantity, + createdAt: batch.createdAt, + creator: batch.creator, + exists: batch.exists, + isRecalled: batch.isRecalled + } + }; + } catch (error) { + console.error('Error fetching batch from blockchain:', error.message); + return { + success: false, + error: error.message, + message: 'Failed to fetch batch from blockchain' + }; + } + } + + /** + * Check if blockchain service is available + * @returns {boolean} + */ + isAvailable() { + return this.isInitialized && this.contract !== null; + } + + /** + * Get contract instance for external use (e.g., event listeners) + * @returns {ethers.Contract|null} + */ + getContract() { + return this.contract; + } +} + +// Export singleton instance +module.exports = new BlockchainService(); diff --git a/backend/services/notificationService.js b/backend/services/notificationService.js new file mode 100644 index 0000000..e49aad4 --- /dev/null +++ b/backend/services/notificationService.js @@ -0,0 +1,198 @@ +/** + * NotificationService - Handles all notifications and alerts + * Extracted from server.js to follow Separation of Concerns principle + */ + +class NotificationService { + constructor() { + this.notificationHistory = []; + this.maxHistorySize = 100; + } + + /** + * Log a notification to console and history + * @param {string} type - Notification type + * @param {string} message - Notification message + * @param {Object} metadata - Additional metadata + */ + log(type, message, metadata = {}) { + const notification = { + type, + message, + metadata, + timestamp: new Date().toISOString() + }; + + // Keep history limited + if (this.notificationHistory.length >= this.maxHistorySize) { + this.notificationHistory.shift(); + } + this.notificationHistory.push(notification); + + // Log based on type + switch (type) { + case 'alert': + case 'recall': + console.warn(`[${type.toUpperCase()}] ${message}`, metadata); + break; + case 'error': + console.error(`[${type.toUpperCase()}] ${message}`, metadata); + break; + default: + console.log(`[${type.toUpperCase()}] ${message}`, metadata); + } + + return notification; + } + + /** + * Alert about a recalled batch being viewed + * @param {string} batchId - Batch identifier + */ + alertRecall(batchId) { + return this.log('alert', `🚨 ALERT: Recalled batch viewed: ${batchId}`, { batchId }); + } + + /** + * Send recall notification when a batch is recalled + * @param {Object} batch - Batch document + * @param {Object} adminUser - Admin user who initiated recall + */ + sendRecallNotification(batch, adminUser) { + return this.log('recall', `Batch ${batch.batchId} has been recalled`, { + batchId: batch.batchId, + cropType: batch.cropType, + quantity: batch.quantity, + recalledBy: adminUser.email, + recalledAt: new Date().toISOString() + }); + } + + /** + * Notify about successful batch creation + * @param {string} batchId - Batch identifier + * @param {Object} user - User who created the batch + */ + notifyBatchCreated(batchId, user) { + return this.log('info', `Batch created: ${batchId}`, { + batchId, + createdBy: user.email || user.id, + timestamp: new Date().toISOString() + }); + } + + /** + * Notify about batch update + * @param {string} batchId - Batch identifier + * @param {string} stage - New stage + * @param {Object} user - User who updated the batch + */ + notifyBatchUpdated(batchId, stage, user) { + return this.log('info', `Batch updated: ${batchId} to stage ${stage}`, { + batchId, + stage, + updatedBy: user.email || user.id, + timestamp: new Date().toISOString() + }); + } + + /** + * Notify about security events + * @param {string} eventType - Type of security event + * @param {Object} details - Event details + */ + notifySecurityEvent(eventType, details) { + return this.log('security', `Security event: ${eventType}`, { + ...details, + timestamp: new Date().toISOString() + }); + } + + /** + * Notify about errors + * @param {string} operation - Operation that failed + * @param {Error} error - Error object + */ + notifyError(operation, error) { + return this.log('error', `Error in ${operation}: ${error.message}`, { + operation, + error: error.message, + stack: error.stack, + timestamp: new Date().toISOString() + }); + } + + /** + * Get notification history + * @param {number} limit - Maximum number of notifications to return + * @returns {Array} - Notification history + */ + getHistory(limit = 50) { + return this.notificationHistory.slice(-limit); + } + + /** + * Clear notification history + */ + clearHistory() { + this.notificationHistory = []; + } + + /** + * Get notifications by type + * @param {string} type - Notification type + * @returns {Array} - Filtered notifications + */ + getByType(type) { + return this.notificationHistory.filter(n => n.type === type); + } + + /** + * Send email notification (placeholder for future implementation) + * @param {string} to - Recipient email + * @param {string} subject - Email subject + * @param {string} body - Email body + */ + async sendEmail(to, subject, body) { + // Placeholder for email integration (e.g., SendGrid, Nodemailer) + console.log(`[EMAIL] Would send email to ${to}: ${subject}`); + + this.log('email', `Email queued: ${subject}`, { to, subject }); + + // Future implementation: + // const transporter = require('./config/email'); + // await transporter.sendMail({ to, subject, html: body }); + } + + /** + * Send push notification (placeholder for future implementation) + * @param {string} userId - User ID + * @param {string} title - Notification title + * @param {string} body - Notification body + */ + async sendPushNotification(userId, title, body) { + // Placeholder for push notifications (e.g., Firebase Cloud Messaging) + console.log(`[PUSH] Would send push to user ${userId}: ${title}`); + + this.log('push', `Push notification: ${title}`, { userId, title, body }); + } + + /** + * Broadcast to WebSocket clients (placeholder for future implementation) + * @param {string} event - Event name + * @param {Object} data - Data to broadcast + */ + async broadcast(event, data) { + // Placeholder for WebSocket broadcasting + console.log(`[WS] Would broadcast ${event}:`, data); + + this.log('broadcast', `WebSocket broadcast: ${event}`, { event, data }); + + // Future implementation: + // const wss = require('./config/websocket'); + // wss.clients.forEach(client => client.send(JSON.stringify({ event, data }))); + } +} + +// Export singleton instance +module.exports = new NotificationService();