'use strict'; /** * Redis Cache Helper for Partner Authentication * Provides a distributed cache that can be shared across different processes */ const Redis = require('ioredis'); const env = require('./env'); const logger = require('./logger'); const pino = logger.child('redis_cache'); class RedisCache { constructor() { this.redis = null; this.isConnected = false; this.fallbackCache = new Map(); // In-memory fallback this.initialize(); } initialize() { try { this.redis = new Redis({ host: env.REDIS_HOST || 'localhost', port: env.REDIS_PORT || 6379, password: env.REDIS_PWD, retryDelayOnFailover: 100, enableReadyCheck: false, lazyConnect: true, maxRetriesPerRequest: 3 }); this.redis.on('connect', () => { this.isConnected = true; pino.info('Redis cache connected'); }); this.redis.on('error', (err) => { this.isConnected = false; pino.error({ err }, 'Redis cache error'); }); this.redis.on('close', () => { this.isConnected = false; pino.warn('Redis cache connection closed'); }); } catch (error) { pino.error({ err: error }, 'Failed to initialize Redis cache'); } } /** * Generate cache key for partner authentication * @param {string} partnerCode - Partner code (e.g., 'SATLOC') * @param {string} customerId - Customer ID * @returns {string} Cache key */ getAuthCacheKey(partnerCode, customerId) { return `partner:auth:${partnerCode}:${customerId}`; } /** * Set authentication data in cache with expiration * @param {string} partnerCode - Partner code * @param {string} customerId - Customer ID * @param {object} authData - Authentication data to cache * @param {number} ttlSeconds - Time to live in seconds (default: 1 hour) * @returns {Promise} Success status */ async setAuth(partnerCode, customerId, authData, ttlSeconds = 3600) { const key = this.getAuthCacheKey(partnerCode, customerId); const dataToCache = { ...authData, cachedAt: Date.now(), expiresAt: Date.now() + (ttlSeconds * 1000) }; if (this.isConnected) { try { const serializedData = JSON.stringify(dataToCache); await this.redis.setex(key, ttlSeconds, serializedData); pino.debug(`Cached auth data in Redis for ${partnerCode}:${customerId}`); return true; } catch (error) { pino.error({ err: error }, 'Failed to cache auth data in Redis'); } } // Fallback to in-memory cache this.fallbackCache.set(key, dataToCache); pino.debug(`Cached auth data in memory for ${partnerCode}:${customerId}`); return true; } /** * Get authentication data from cache * @param {string} partnerCode - Partner code * @param {string} customerId - Customer ID * @returns {Promise} Cached auth data or null */ async getAuth(partnerCode, customerId) { const key = this.getAuthCacheKey(partnerCode, customerId); if (this.isConnected) { try { const cachedData = await this.redis.get(key); if (cachedData) { const authData = JSON.parse(cachedData); pino.debug(`Retrieved cached auth data from Redis for ${partnerCode}:${customerId}`); return authData; } } catch (error) { pino.error({ err: error }, 'Failed to retrieve cached auth data from Redis'); } } // Fallback to in-memory cache const memoryData = this.fallbackCache.get(key); if (memoryData) { // Check if in-memory data is expired if (memoryData.expiresAt && memoryData.expiresAt > Date.now()) { pino.debug(`Retrieved cached auth data from memory for ${partnerCode}:${customerId}`); return memoryData; } else { // Remove expired data this.fallbackCache.delete(key); } } return null; } /** * Delete authentication data from cache * @param {string} partnerCode - Partner code * @param {string} customerId - Customer ID (optional, if not provided clears all for partner) * @returns {Promise} Success status */ async deleteAuth(partnerCode, customerId = null) { let deleted = false; if (this.isConnected) { try { if (customerId) { const key = this.getAuthCacheKey(partnerCode, customerId); await this.redis.del(key); pino.debug(`Deleted cached auth data from Redis for ${partnerCode}:${customerId}`); } else { // Delete all auth data for the partner const pattern = this.getAuthCacheKey(partnerCode, '*'); const keys = await this.redis.keys(pattern); if (keys.length > 0) { await this.redis.del(...keys); pino.debug(`Deleted ${keys.length} cached auth entries from Redis for ${partnerCode}`); } } deleted = true; } catch (error) { pino.error({ err: error }, 'Failed to delete cached auth data from Redis'); } } // Also clean from fallback cache if (customerId) { const key = this.getAuthCacheKey(partnerCode, customerId); this.fallbackCache.delete(key); } else { // Delete all entries for the partner from memory const keysToDelete = []; for (const key of this.fallbackCache.keys()) { if (key.startsWith(`partner:auth:${partnerCode}:`)) { keysToDelete.push(key); } } keysToDelete.forEach(key => this.fallbackCache.delete(key)); pino.debug(`Deleted ${keysToDelete.length} cached auth entries from memory for ${partnerCode}`); } return true; } /** * Check if auth data is still valid based on expiration time * @param {object} authData - Cached auth data * @param {number} healthCheckInterval - Health check interval in ms * @returns {boolean} Whether auth data is still valid */ isAuthValid(authData, healthCheckInterval = 30000) { if (!authData) return false; const now = Date.now(); return authData.expiresAt > now && authData.lastHealthCheck && (now - authData.lastHealthCheck) < healthCheckInterval; } /** * Generic get — retrieves and JSON-parses a value from cache. * Falls back to in-memory cache when Redis is unavailable. * @param {string} key * @returns {Promise} */ async get(key) { if (this.isConnected) { try { const raw = await this.redis.get(key); if (raw) return JSON.parse(raw); } catch (err) { pino.error({ err }, `Redis get failed for key ${key}`); } } const mem = this.fallbackCache.get(key); if (mem) { if (!mem._expiresAt || mem._expiresAt > Date.now()) return mem._value; this.fallbackCache.delete(key); } return null; } /** * Generic set — JSON-serialises and stores a value in cache. * Falls back to in-memory cache when Redis is unavailable. * @param {string} key * @param {any} value * @param {number} ttlSeconds - Default 60 s * @returns {Promise} */ async set(key, value, ttlSeconds = 60) { if (this.isConnected) { try { await this.redis.setex(key, ttlSeconds, JSON.stringify(value)); return true; } catch (err) { pino.error({ err }, `Redis set failed for key ${key}`); } } this.fallbackCache.set(key, { _value: value, _expiresAt: Date.now() + ttlSeconds * 1000 }); return true; } /** * Delete all cache keys whose names start with the given prefix pattern * (supports a trailing wildcard, e.g. 'jobs:list:abc123:*'). * @param {string} pattern * @returns {Promise} number of entries removed */ async delByPattern(pattern) { let count = 0; if (this.isConnected) { try { const keys = await this.redis.keys(pattern); if (keys.length > 0) { await this.redis.del(...keys); count += keys.length; } } catch (err) { pino.error({ err }, `Redis delByPattern failed for pattern ${pattern}`); } } // Also purge the in-memory fallback const prefix = pattern.endsWith('*') ? pattern.slice(0, -1) : pattern; for (const key of this.fallbackCache.keys()) { if (key.startsWith(prefix)) { this.fallbackCache.delete(key); count++; } } return count; } /** * Close Redis connection */ async disconnect() { if (this.redis) { await this.redis.disconnect(); this.isConnected = false; pino.info('Redis cache disconnected'); } } } // Export singleton instance module.exports = new RedisCache();