import dotenv from 'dotenv'; dotenv.config(); import NodeCache from 'node-cache'; import fetch from 'node-fetch'; import { createLogger } from '../utils/logger.js'; const logger = createLogger('APPWRITE-PRS'); const cache = new NodeCache({ stdTTL: 3600, checkperiod: 600, useClones: false }); // ============================================================================ // CUSTOM ERROR CLASSES // ============================================================================ class AppwriteError extends Error { constructor(message, code, statusCode) { super(message); this.name = 'AppwriteError'; this.code = code; this.statusCode = statusCode; } } class AppwriteTimeoutError extends AppwriteError { constructor(timeout) { super(`Request timed out after ${timeout}ms`, 'TIMEOUT', 408); this.name = 'AppwriteTimeoutError'; } } class AppwriteConfigError extends AppwriteError { constructor(message) { super(message, 'CONFIG_ERROR', 500); this.name = 'AppwriteConfigError'; } } // ============================================================================ // CIRCUIT BREAKER (Prevents cascading failures) // ============================================================================ class CircuitBreaker { constructor(threshold = 5, timeout = 60000) { this.failureThreshold = threshold; this.timeout = timeout; this.failures = 0; this.state = 'CLOSED'; this.nextAttempt = Date.now(); } async execute(fn) { if (this.state === 'OPEN') { if (Date.now() < this.nextAttempt) { throw new Error('Circuit breaker is OPEN'); } this.state = 'HALF_OPEN'; } try { const result = await fn(); this.onSuccess(); return result; } catch (error) { this.onFailure(); throw error; } } onSuccess() { this.failures = 0; this.state = 'CLOSED'; } onFailure() { this.failures++; if (this.failures >= this.failureThreshold) { this.state = 'OPEN'; this.nextAttempt = Date.now() + this.timeout; logger.error('CIRCUIT', 'Circuit breaker opened', { failures: this.failures, nextAttempt: new Date(this.nextAttempt).toISOString() }); } } getState() { return { state: this.state, failures: this.failures, nextAttempt: this.state === 'OPEN' ? new Date(this.nextAttempt).toISOString() : null }; } reset() { this.failures = 0; this.state = 'CLOSED'; this.nextAttempt = Date.now(); } } // ============================================================================ // METRICS COLLECTOR // ============================================================================ class MetricsCollector { constructor() { this.metrics = { totalRequests: 0, successfulRequests: 0, failedRequests: 0, cacheHits: 0, cacheMisses: 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; 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, successRate: `${successRate}%`, cacheHitRate: `${cacheHitRate}%` }; } reset() { this.metrics = { totalRequests: 0, successfulRequests: 0, failedRequests: 0, cacheHits: 0, cacheMisses: 0, totalResponseTime: 0, errors: {}, lastRequest: null, lastSuccess: null, lastError: null }; } } // ============================================================================ // APPWRITE PRS SERVICE // ============================================================================ class AppwritePRSService { constructor() { this.config = { endpoint: process.env.APPWRITE_ENDPOINT || 'https://cloud.appwrite.io/v1', projectId: process.env.APPWRITE_PROJECT_ID, functionId: process.env.APPWRITE_FUNCTION_ID_PRS, apiKey: process.env.APPWRITE_API_KEY, timeout: parseInt(process.env.APPWRITE_TIMEOUT) || 15000, retryAttempts: parseInt(process.env.APPWRITE_RETRY_ATTEMPTS) || 2, retryDelay: parseInt(process.env.APPWRITE_RETRY_DELAY) || 1000 }; this.circuitBreaker = new CircuitBreaker(5, 60000); this.metrics = new MetricsCollector(); this._validateConfig(); this._initialize(); } _validateConfig() { const { projectId, functionId } = this.config; if (!projectId || !functionId) { logger.warn('CONFIG', 'Missing PROJECT_ID or FUNCTION_ID - service disabled'); this.enabled = false; return; } if (projectId.length < 10) { throw new AppwriteConfigError('Invalid PROJECT_ID format'); } if (functionId.length < 10) { throw new AppwriteConfigError('Invalid FUNCTION_ID format'); } this.enabled = true; } _initialize() { if (!this.enabled) return; const { endpoint, functionId } = this.config; this.functionUrl = `${endpoint}/functions/${functionId}/executions`; logger.info('INIT', 'Service initialized', { endpoint: this.functionUrl, timeout: this.config.timeout, retryAttempts: this.config.retryAttempts }); } // ======================================================================== // MAIN METHOD // ======================================================================== async getMemberData(name, type, constituency = null, state = null) { if (!this.enabled) { logger.warn('DISABLED', 'Service not configured'); return this._getEmptyResponse(); } const requestId = this._generateRequestId(); this.metrics.recordRequest(); const cacheKey = this._getCacheKey(name, type, constituency); const cached = cache.get(cacheKey); if (cached) { this.metrics.recordCacheHit(); logger.success(requestId, `Cache hit: ${name}`, { type, constituency }); return cached; } this.metrics.recordCacheMiss(); try { const data = await this.circuitBreaker.execute(async () => { return await this._fetchWithRetry(requestId, { name, type, constituency: constituency || '', state: state || '' }); }); if (data.found && data.party !== 'Unknown') { cache.set(cacheKey, data); logger.success(requestId, `Data fetched and cached: ${name}`, { party: data.party, constituency: data.constituency }); } return data; } catch (error) { this.metrics.recordFailure(error); logger.error(requestId, 'Failed to fetch data', error); return this._getEmptyResponse(); } } // ======================================================================== // FETCH WITH RETRY LOGIC // ======================================================================== async _fetchWithRetry(requestId, params, attempt = 1) { const startTime = Date.now(); try { logger.info(requestId, `Attempt ${attempt}/${this.config.retryAttempts + 1}`, params); const data = await this._executeFetch(requestId, params); const duration = Date.now() - startTime; this.metrics.recordSuccess(duration); logger.success(requestId, `Response received in ${duration}ms`); return data; } catch (error) { const duration = Date.now() - startTime; if (error instanceof AppwriteTimeoutError || attempt > this.config.retryAttempts) { logger.error(requestId, `Failed after ${attempt} attempt(s)`, { duration, error: error.message }); throw error; } 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._fetchWithRetry(requestId, params, attempt + 1); } } // ======================================================================== // CORE FETCH LOGIC // ======================================================================== async _executeFetch(requestId, params) { const controller = new AbortController(); const timeout = setTimeout(() => { controller.abort(); }, this.config.timeout); try { const response = await fetch(this.functionUrl, { method: 'POST', headers: this._getHeaders(), body: JSON.stringify(params), signal: controller.signal }); clearTimeout(timeout); if (!response.ok) { const errorText = await response.text(); throw new AppwriteError( `HTTP ${response.status}: ${errorText}`, 'HTTP_ERROR', response.status ); } const result = await response.json(); return this._parseResponse(requestId, result, params.type); } catch (error) { clearTimeout(timeout); if (error.name === 'AbortError') { throw new AppwriteTimeoutError(this.config.timeout); } throw error; } } // ======================================================================== // RESPONSE PARSING // ======================================================================== _parseResponse(requestId, result, type) { logger.debug(requestId, 'Raw Appwrite execution result', { status: result.status, hasResponseBody: !!result.responseBody, hasResponse: !!result.response, responseBodyType: typeof result.responseBody }); let responseData; if (result.responseBody) { try { responseData = typeof result.responseBody === 'string' ? JSON.parse(result.responseBody) : result.responseBody; logger.debug(requestId, 'Parsed responseBody successfully', { success: responseData.success, hasData: !!responseData.data, dataKeys: responseData.data ? Object.keys(responseData.data).length : 0 }); } catch (error) { logger.error(requestId, 'Failed to parse responseBody', { error: error.message, responseBody: result.responseBody?.substring(0, 200) }); return this._getEmptyResponse(); } } else if (result.response) { try { responseData = typeof result.response === 'string' ? JSON.parse(result.response) : result.response; } catch (error) { logger.error(requestId, 'Failed to parse response', error); return this._getEmptyResponse(); } } else if (result.success) { responseData = result; } else { logger.error(requestId, 'No valid response found', { keys: Object.keys(result) }); return this._getEmptyResponse(); } if (!responseData.success) { logger.warn(requestId, 'Function returned success=false', { error: responseData.error || 'Unknown error' }); return this._getEmptyResponse(); } if (!responseData.data) { logger.warn(requestId, 'Response missing data field', { responseKeys: Object.keys(responseData) }); return this._getEmptyResponse(); } logger.info(requestId, 'Valid PRS data received from Appwrite', { name: responseData.data.name, party: responseData.data.party, constituency: responseData.data.constituency, attendance: responseData.data.attendance, age: responseData.data.age, totalFields: Object.keys(responseData.data).length }); const normalized = this._normalizeData(responseData.data, type); logger.success(requestId, 'Data normalized successfully', { found: normalized.found, party: normalized.party, hasPersonal: Object.keys(normalized.personal).length, hasPerformance: Object.keys(normalized.performance).length }); return normalized; } _normalizeData(data, type) { return { found: true, source: 'appwrite-prs', memberType: type, name: data.name || '', imageUrl: data.imageUrl || '', state: data.state || 'Unknown', constituency: data.constituency || 'Unknown', party: data.party || 'Unknown', personal: { age: data.age || '', gender: data.gender || '', education: data.education || '', termStart: data.termStart || '', termEnd: data.termEnd || '', noOfTerm: data.noOfTerm || '', membership: data.membership || '' }, performance: { attendance: data.attendance || '', natAttendance: data.natAttendance || '', stateAttendance: data.stateAttendance || '', debates: data.debates || '', natDebates: data.natDebates || '', stateDebates: data.stateDebates || '', questions: data.questions || '', natQuestions: data.natQuestions || '', stateQuestions: data.stateQuestions || '', pmb: data.pmb || '', natPMB: data.natPMB || '', statePMB: data.statePMB || '' }, tables: { attendance: data.attendanceTable || '', debates: data.debatesTable || '', questions: data.questionsTable || '' } }; } // ======================================================================== // 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, type, constituency) { const parts = [ 'appwrite-prs', type, name.toLowerCase().trim(), constituency?.toLowerCase().trim() || '' ]; return parts.filter(Boolean).join(':'); } _generateRequestId() { return `aws-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; } _getEmptyResponse() { return { found: false, source: 'appwrite-prs', imageUrl: '', state: 'Unknown', constituency: 'Unknown', party: 'Unknown', personal: {}, performance: {}, tables: {} }; } _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(), metrics: this.metrics.getStats(), circuitBreaker: this.circuitBreaker.getState(), config: { endpoint: this.functionUrl, projectId: this.config.projectId ? `${this.config.projectId.slice(0, 8)}...` : 'not set', timeout: this.config.timeout, retryAttempts: this.config.retryAttempts } }; } async healthCheck() { if (!this.enabled) { return { status: 'disabled', message: 'Service not configured', healthy: false }; } try { const startTime = Date.now(); const testData = await this._executeFetch('health-check', { name: 'Health Check', type: 'MP', constituency: '', state: '' }); const duration = Date.now() - startTime; return { status: 'healthy', healthy: true, responseTime: duration, circuitBreaker: this.circuitBreaker.getState().state }; } catch (error) { return { status: 'unhealthy', healthy: false, error: error.message, circuitBreaker: this.circuitBreaker.getState().state }; } } resetMetrics() { this.metrics.reset(); this.circuitBreaker.reset(); logger.info('ADMIN', 'Metrics and circuit breaker reset'); } } // ============================================================================ // EXPORT SINGLETON // ============================================================================ export default new AppwritePRSService();