Spaces:
Runtime error
Runtime error
| import dotenv from 'dotenv'; | |
| dotenv.config(); | |
| import NodeCache from 'node-cache'; | |
| import crypto from 'crypto'; | |
| import { createLogger } from '../utils/logger.js'; | |
| import fileStorage from '../utils/fileStorage.js'; | |
| import appwriteService from './AppwriteService.js'; | |
| const logger = createLogger('CANDIDATE'); | |
| const cache = new NodeCache({ | |
| stdTTL: 7200, | |
| checkperiod: 600, | |
| useClones: false | |
| }); | |
| // ============================================================================ | |
| // CUSTOM ERRORS | |
| // ============================================================================ | |
| class CandidateServiceError extends Error { | |
| constructor(message, code, statusCode) { | |
| super(message); | |
| this.name = 'CandidateServiceError'; | |
| this.code = code; | |
| this.statusCode = statusCode; | |
| } | |
| } | |
| class CandidateTimeoutError extends CandidateServiceError { | |
| constructor(timeout) { | |
| super(`Request timed out after ${timeout}ms`, 'TIMEOUT', 408); | |
| this.name = 'CandidateTimeoutError'; | |
| } | |
| } | |
| class CandidateNotFoundError extends CandidateServiceError { | |
| constructor(name) { | |
| super(`Candidate not found: ${name}`, 'NOT_FOUND', 404); | |
| this.name = 'CandidateNotFoundError'; | |
| } | |
| } | |
| // ============================================================================ | |
| // REQUEST DEDUPLICATOR (Prevents duplicate simultaneous requests) | |
| // ============================================================================ | |
| class RequestDeduplicator { | |
| constructor() { | |
| this.pending = new Map(); | |
| } | |
| async deduplicate(key, fn) { | |
| // If request is already in flight, wait for it | |
| if (this.pending.has(key)) { | |
| logger.debug('DEDUP', `Reusing in-flight request: ${key}`); | |
| return this.pending.get(key); | |
| } | |
| // Execute new request | |
| const promise = fn() | |
| .finally(() => { | |
| this.pending.delete(key); | |
| }); | |
| this.pending.set(key, promise); | |
| return promise; | |
| } | |
| clear() { | |
| this.pending.clear(); | |
| } | |
| getStats() { | |
| return { | |
| pendingRequests: this.pending.size, | |
| keys: Array.from(this.pending.keys()) | |
| }; | |
| } | |
| } | |
| // ============================================================================ | |
| // METRICS COLLECTOR | |
| // ============================================================================ | |
| class CandidateMetrics { | |
| constructor() { | |
| this.metrics = { | |
| totalRequests: 0, | |
| successfulRequests: 0, | |
| failedRequests: 0, | |
| cacheHits: 0, | |
| cacheMisses: 0, | |
| timeouts: 0, | |
| totalResponseTime: 0, | |
| errors: {}, | |
| lastRequest: null, | |
| lastSuccess: null, | |
| lastError: null | |
| }; | |
| } | |
| recordRequest() { | |
| this.metrics.totalRequests++; | |
| this.metrics.lastRequest = new Date().toISOString(); | |
| } | |
| recordSuccess(duration) { | |
| this.metrics.successfulRequests++; | |
| this.metrics.totalResponseTime += duration; | |
| this.metrics.lastSuccess = new Date().toISOString(); | |
| } | |
| recordFailure(error) { | |
| this.metrics.failedRequests++; | |
| const errorType = error.name || 'UnknownError'; | |
| this.metrics.errors[errorType] = (this.metrics.errors[errorType] || 0) + 1; | |
| if (error instanceof CandidateTimeoutError) { | |
| this.metrics.timeouts++; | |
| } | |
| this.metrics.lastError = { | |
| timestamp: new Date().toISOString(), | |
| type: errorType, | |
| message: error.message | |
| }; | |
| } | |
| recordCacheHit() { | |
| this.metrics.cacheHits++; | |
| } | |
| recordCacheMiss() { | |
| this.metrics.cacheMisses++; | |
| } | |
| getStats() { | |
| const avgResponseTime = this.metrics.successfulRequests > 0 | |
| ? Math.round(this.metrics.totalResponseTime / this.metrics.successfulRequests) | |
| : 0; | |
| const successRate = this.metrics.totalRequests > 0 | |
| ? ((this.metrics.successfulRequests / this.metrics.totalRequests) * 100).toFixed(2) | |
| : 0; | |
| const cacheHitRate = (this.metrics.cacheHits + this.metrics.cacheMisses) > 0 | |
| ? ((this.metrics.cacheHits / (this.metrics.cacheHits + this.metrics.cacheMisses)) * 100).toFixed(2) | |
| : 0; | |
| return { | |
| ...this.metrics, | |
| avgResponseTime: `${avgResponseTime}ms`, | |
| successRate: `${successRate}%`, | |
| cacheHitRate: `${cacheHitRate}%` | |
| }; | |
| } | |
| reset() { | |
| this.metrics = { | |
| totalRequests: 0, | |
| successfulRequests: 0, | |
| failedRequests: 0, | |
| cacheHits: 0, | |
| cacheMisses: 0, | |
| timeouts: 0, | |
| totalResponseTime: 0, | |
| errors: {}, | |
| lastRequest: null, | |
| lastSuccess: null, | |
| lastError: null | |
| }; | |
| } | |
| } | |
| // ============================================================================ | |
| // CANDIDATE SERVICE | |
| // ============================================================================ | |
| class CandidateService { | |
| constructor() { | |
| this.config = { | |
| timeout: parseInt(process.env.CANDIDATE_TIMEOUT) || 20000, | |
| retryAttempts: parseInt(process.env.CANDIDATE_RETRY_ATTEMPTS) || 2, | |
| retryDelay: parseInt(process.env.CANDIDATE_RETRY_DELAY) || 1000, | |
| myNetaBaseUrl: 'https://www.myneta.info', | |
| functionId: process.env.APPWRITE_FUNCTION_ID // Specific function ID for candidates if different | |
| }; | |
| this.metrics = new CandidateMetrics(); | |
| this.deduplicator = new RequestDeduplicator(); | |
| this.enabled = appwriteService.enabled; | |
| } | |
| // ======================================================================== | |
| // ENCRYPTION/DECRYPTION METHODS | |
| // ======================================================================== | |
| _encrypt(text) { | |
| const algorithm = 'aes-256-cbc'; | |
| const key = crypto.scryptSync('fixkaroweb3.0secretkey', 'salt', 32); | |
| const iv = crypto.randomBytes(16); | |
| const cipher = crypto.createCipheriv(algorithm, key, iv); | |
| let encrypted = cipher.update(text, 'utf8', 'hex'); | |
| encrypted += cipher.final('hex'); | |
| return iv.toString('hex') + ':' + encrypted; | |
| } | |
| _decrypt(encryptedText) { | |
| try { | |
| const algorithm = 'aes-256-cbc'; | |
| const key = crypto.scryptSync('fixkaroweb3.0secretkey', 'salt', 32); | |
| const parts = encryptedText.split(':'); | |
| if (parts.length !== 2) return null; | |
| const iv = Buffer.from(parts[0], 'hex'); | |
| const encrypted = parts[1]; | |
| const decipher = crypto.createDecipheriv(algorithm, key, iv); | |
| let decrypted = decipher.update(encrypted, 'hex', 'utf8'); | |
| decrypted += decipher.final('utf8'); | |
| return decrypted; | |
| } catch (error) { | |
| return null; | |
| } | |
| } | |
| async _storeMapping(encryptedMeow, originalMeow, encryptedBhaw, originalBhaw) { | |
| try { | |
| const mappingFile = 'meow_bhaw_mappings.json'; | |
| let mappings = {}; | |
| // Load existing mappings | |
| try { | |
| const existing = await fileStorage.getFile(mappingFile); | |
| if (existing.found) { | |
| mappings = JSON.parse(existing.data); | |
| } | |
| } catch (error) { | |
| // File doesn't exist, start fresh | |
| } | |
| // Add new mapping | |
| mappings[encryptedMeow] = { meow: originalMeow, bhaw: originalBhaw }; | |
| // Save updated mappings | |
| await fileStorage.saveFile(mappingFile, JSON.stringify(mappings, null, 2)); | |
| } catch (error) { | |
| logger.error('MAPPING', 'Failed to store mapping', error); | |
| } | |
| } | |
| async _getMapping(encryptedMeow) { | |
| try { | |
| const mappingFile = 'meow_bhaw_mappings.json'; | |
| const existing = await fileStorage.getFile(mappingFile); | |
| if (existing.found) { | |
| const mappings = JSON.parse(existing.data); | |
| return mappings[encryptedMeow] || null; | |
| } | |
| } catch (error) { | |
| logger.error('MAPPING', 'Failed to get mapping', error); | |
| } | |
| return null; | |
| } | |
| // ======================================================================== | |
| // MAIN METHOD - WITH VALIDATION | |
| // ======================================================================== | |
| async getCandidateData(name, constituency = '', party = '', meow = '', bhaw = '') { | |
| const requestId = this._generateRequestId(); | |
| // Validate input | |
| if (!name || name.trim() === '') { | |
| logger.error(requestId, 'Candidate name is required'); | |
| return this._getErrorResponse('Candidate name is required'); | |
| } | |
| if (!this.enabled) { | |
| logger.warn(requestId, 'Service not configured'); | |
| return this._getErrorResponse('Service not configured'); | |
| } | |
| this.metrics.recordRequest(); | |
| const normalizedName = name.trim(); | |
| // Decode encrypted meow and bhaw if provided | |
| let decodedMeow = meow; | |
| let decodedBhaw = bhaw; | |
| if (meow && meow.includes(':')) { | |
| const mapping = await this._getMapping(meow); | |
| if (mapping) { | |
| decodedMeow = mapping.meow; | |
| decodedBhaw = mapping.bhaw; | |
| logger.debug(requestId, 'Decoded encrypted meow/bhaw', { decodedMeow, decodedBhaw }); | |
| } else { | |
| logger.warn(requestId, 'Could not decode encrypted meow', { meow }); | |
| } | |
| } | |
| const cacheKey = this._getCacheKey(normalizedName, constituency, party); | |
| // Check cache | |
| const cached = cache.get(cacheKey); | |
| if (cached) { | |
| this.metrics.recordCacheHit(); | |
| logger.success(requestId, `Cache hit: ${normalizedName}`); | |
| return cached; | |
| } | |
| this.metrics.recordCacheMiss(); | |
| // Deduplicate simultaneous requests | |
| return this.deduplicator.deduplicate(cacheKey, async () => { | |
| return await this._fetchCandidateData(requestId, { | |
| name: normalizedName, | |
| constituency, | |
| party, | |
| meow: decodedMeow, | |
| bhaw: decodedBhaw | |
| }); | |
| }); | |
| } | |
| // ======================================================================== | |
| // FETCH WITH RETRY | |
| // ======================================================================== | |
| async _fetchCandidateData(requestId, params, attempt = 1) { | |
| const startTime = Date.now(); | |
| try { | |
| logger.info(requestId, `Attempt ${attempt}/${this.config.retryAttempts + 1}`, { | |
| name: params.name, | |
| constituency: params.constituency || 'N/A', | |
| party: params.party || 'N/A' | |
| }); | |
| // Use AppwriteService to execute function | |
| // Pass the payload directly. AppwriteService handles JSON.stringify. | |
| const payload = { | |
| test: 'search', | |
| name: params.name, | |
| constituency: params.constituency, | |
| party: params.party, | |
| meow: params.meow, | |
| bhaw: params.bhaw | |
| }; | |
| // Pass functionId if specific one is needed, otherwise AppwriteService uses default | |
| const result = await appwriteService.executeFunction(payload, requestId, this.config.functionId); | |
| // Parse the result structure from AppwriteService | |
| const data = await this._parseResponse(requestId, result, params.name); | |
| const duration = Date.now() - startTime; | |
| this.metrics.recordSuccess(duration); | |
| logger.success(requestId, `Data fetched in ${duration}ms`, { | |
| name: params.name, | |
| hasAssets: !!data.assets, // Changed from data.data.assets | |
| hasCriminalCases: !!data.criminalCases // Changed from data.data.criminalCases | |
| }); | |
| // Cache successful result | |
| const cacheKey = this._getCacheKey(params.name, params.constituency, params.party); | |
| cache.set(cacheKey, data); | |
| return data; | |
| } catch (error) { | |
| const duration = Date.now() - startTime; | |
| // Don't retry on certain errors | |
| if (error instanceof CandidateTimeoutError || | |
| error instanceof CandidateNotFoundError || | |
| attempt > this.config.retryAttempts) { | |
| this.metrics.recordFailure(error); | |
| logger.error(requestId, `Failed after ${attempt} attempt(s)`, { | |
| duration, | |
| error: error.message | |
| }); | |
| return this._getErrorResponse(error.message, params.name); | |
| } | |
| // Exponential backoff | |
| const delay = this.config.retryDelay * Math.pow(2, attempt - 1); | |
| logger.warn(requestId, `Retrying in ${delay}ms...`, { | |
| attempt, | |
| error: error.message | |
| }); | |
| await this._sleep(delay); | |
| return this._fetchCandidateData(requestId, params, attempt + 1); | |
| } | |
| } | |
| // ======================================================================== | |
| // RESPONSE PARSING | |
| // ======================================================================== | |
| async _parseResponse(requestId, result, name) { | |
| // result is the object returned by AppwriteService.executeFunction | |
| // it contains { success: true, data: { ... }, execution: { ... } } | |
| const responseData = result.data; // This is the parsed JSON body from the function | |
| if (!responseData || responseData.success === false) { | |
| if (responseData?.error?.includes('not found') || | |
| responseData?.data?.error?.includes('not found')) { | |
| throw new CandidateNotFoundError(name); | |
| } | |
| throw new CandidateServiceError( | |
| responseData?.error || 'Failed to fetch data from Appwrite', | |
| 'EXECUTION_FAILED', | |
| 500 | |
| ); | |
| } | |
| const searchUrl = this._getSearchUrl(name); | |
| const candidateData = responseData.data || {}; | |
| // Extract meow and bhaw from assetLink if present | |
| let meow = ''; | |
| let bhaw = ''; | |
| if (candidateData.assetLink) { | |
| const urlMatch = candidateData.assetLink.match(/\/([^\/]+)\/candidate\.php\?candidate_id=(\d+)/); | |
| if (urlMatch) { | |
| bhaw = urlMatch[1]; // e.g., Karnataka2023 | |
| meow = urlMatch[2]; // e.g., 8264 | |
| } | |
| } | |
| // Encrypt meow and bhaw if extracted | |
| let encryptedMeow = ''; | |
| let encryptedBhaw = ''; | |
| if (meow && bhaw) { | |
| encryptedMeow = this._encrypt(meow); | |
| encryptedBhaw = this._encrypt(bhaw); | |
| // Store mapping for future decoding | |
| await this._storeMapping(encryptedMeow, meow, encryptedBhaw, bhaw); | |
| logger.debug(requestId, 'Encrypted meow/bhaw', { encryptedMeow: encryptedMeow.substring(0, 20) + '...', encryptedBhaw: encryptedBhaw.substring(0, 20) + '...' }); | |
| } | |
| // Remove assetLink from candidateData before returning | |
| delete candidateData.assetLink; | |
| // RETURN UNWRAPPED DATA | |
| return { | |
| ...candidateData, | |
| meow: encryptedMeow, | |
| bhaw: encryptedBhaw, | |
| searchUrl: searchUrl, | |
| source: 'appwrite', | |
| timestamp: new Date().toISOString() | |
| }; | |
| } | |
| // ======================================================================== | |
| // HELPER METHODS | |
| // ======================================================================== | |
| _getHeaders() { | |
| const headers = { | |
| 'Content-Type': 'application/json', | |
| 'X-Appwrite-Project': this.config.projectId | |
| }; | |
| if (this.config.apiKey) { | |
| headers['X-Appwrite-Key'] = this.config.apiKey; | |
| } | |
| return headers; | |
| } | |
| _getCacheKey(name, constituency, party) { | |
| const parts = [ | |
| 'candidate', | |
| name.toLowerCase().trim(), | |
| constituency?.toLowerCase().trim() || '', | |
| party?.toLowerCase().trim() || '' | |
| ]; | |
| const keyString = parts.filter(Boolean).join(':'); | |
| if (keyString.length > 100) { | |
| return 'candidate:' + crypto.createHash('md5').update(keyString).digest('hex'); | |
| } | |
| return keyString; | |
| } | |
| _generateRequestId() { | |
| return `cand-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; | |
| } | |
| _getSearchUrl(name) { | |
| return `${this.config.myNetaBaseUrl}/search_myneta.php?q=${encodeURIComponent(name)}`; | |
| } | |
| _getErrorResponse(errorMessage, name = '') { | |
| const searchUrl = name ? this._getSearchUrl(name) : null; | |
| // Also unwrap error response | |
| return { | |
| assetLink: searchUrl, | |
| content: null, | |
| error: errorMessage, | |
| timestamp: new Date().toISOString() | |
| }; | |
| } | |
| _sleep(ms) { | |
| return new Promise(resolve => setTimeout(resolve, ms)); | |
| } | |
| // ======================================================================== | |
| // PUBLIC UTILITY METHODS | |
| // ======================================================================== | |
| clearCache(pattern) { | |
| if (pattern) { | |
| const keys = cache.keys().filter(key => key.includes(pattern)); | |
| cache.del(keys); | |
| logger.info('CACHE', `Cleared ${keys.length} entries`, { pattern }); | |
| } else { | |
| const keyCount = cache.keys().length; | |
| cache.flushAll(); | |
| logger.info('CACHE', `Cleared all ${keyCount} entries`); | |
| } | |
| } | |
| getStats() { | |
| return { | |
| enabled: this.enabled, | |
| cache: { | |
| ...cache.getStats(), | |
| size: cache.keys().length | |
| }, | |
| metrics: this.metrics.getStats(), | |
| deduplicator: this.deduplicator.getStats(), | |
| config: { | |
| timeout: this.config.timeout, | |
| retryAttempts: this.config.retryAttempts, | |
| cacheTime: cache.options.stdTTL | |
| } | |
| }; | |
| } | |
| async healthCheck() { | |
| if (!this.enabled) { | |
| return { | |
| status: 'disabled', | |
| message: 'Service not configured', | |
| healthy: false | |
| }; | |
| } | |
| try { | |
| const startTime = Date.now(); | |
| // Simple health check payload | |
| await appwriteService.executeFunction({ | |
| test: 'health', | |
| name: 'Health Check' | |
| }, 'health-check'); | |
| const duration = Date.now() - startTime; | |
| return { | |
| status: 'healthy', | |
| healthy: true, | |
| responseTime: duration | |
| }; | |
| } catch (error) { | |
| return { | |
| status: 'unhealthy', | |
| healthy: false, | |
| error: error.message | |
| }; | |
| } | |
| } | |
| resetMetrics() { | |
| this.metrics.reset(); | |
| this.deduplicator.clear(); | |
| logger.info('ADMIN', 'Metrics and deduplicator reset'); | |
| } | |
| // ======================================================================== | |
| // BATCH OPERATIONS | |
| // ======================================================================== | |
| async getCandidatesDataBatch(candidates) { | |
| if (!Array.isArray(candidates) || candidates.length === 0) { | |
| throw new Error('Candidates array is required'); | |
| } | |
| logger.info('BATCH', `Processing ${candidates.length} candidates`); | |
| const results = await Promise.allSettled( | |
| candidates.map(c => | |
| this.getCandidateData( | |
| c.name, | |
| c.constituency, | |
| c.party, | |
| c.meow, | |
| c.bhaw | |
| ) | |
| ) | |
| ); | |
| const successful = results.filter(r => r.status === 'fulfilled').length; | |
| const failed = results.length - successful; | |
| logger.info('BATCH', `Completed: ${successful} success, ${failed} failed`); | |
| return results.map((result, index) => ({ | |
| name: candidates[index].name, | |
| status: result.status, | |
| data: result.status === 'fulfilled' ? result.value : null, | |
| error: result.status === 'rejected' ? result.reason.message : null | |
| })); | |
| } | |
| } | |
| // ============================================================================ | |
| // EXPORT SINGLETON INSTANCE | |
| // ============================================================================ | |
| const candidateService = new CandidateService(); | |
| // Backward compatibility export | |
| export async function getCandidateData(name, constituency = '', party = '', meow = '', bhaw = '') { | |
| return candidateService.getCandidateData(name, constituency, party, meow, bhaw); | |
| } | |
| export default candidateService; |