// utils/fileStorage.js - RESULT STORAGE SYSTEM import fs from 'fs'; import path from 'path'; import crypto from 'crypto'; import { fileURLToPath } from 'url'; import { createLogger } from './logger.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const logger = createLogger('STORAGE'); class FileStorage { constructor() { this.storageDir = path.join(__dirname, '..', 'storage'); this.categories = { candidates: path.join(this.storageDir, 'candidates'), prs: path.join(this.storageDir, 'prs'), analytics: path.join(this.storageDir, 'analytics'), cache: path.join(this.storageDir, 'cache'), images: path.join(this.storageDir, 'images') }; this._ensureDirectories(); } _ensureDirectories() { Object.values(this.categories).forEach(dir => { if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } }); } // ======================================================================== // SAVE METHODS // ======================================================================== async saveFile(filename, data) { const filepath = path.join(this.storageDir, filename); try { fs.writeFileSync(filepath, data, 'utf8'); logger.success('SAVE', `File saved: ${filename}`, { filepath }); return { success: true, filepath }; } catch (error) { logger.error('SAVE', `Failed to save file: ${filename}`, error); return { success: false, error: error.message }; } } async getFile(filename) { const filepath = path.join(this.storageDir, filename); try { if (!fs.existsSync(filepath)) { return { found: false, error: 'File not found' }; } const data = fs.readFileSync(filepath, 'utf8'); logger.info('READ', `File retrieved: ${filename}`); return { found: true, data }; } catch (error) { logger.error('READ', `Failed to read file: ${filename}`, error); return { found: false, error: error.message }; } } async saveCandidateData(name, data, metadata = {}) { const filename = this._sanitizeFilename(name) + '.json'; const filepath = path.join(this.categories.candidates, filename); const record = { name, timestamp: new Date().toISOString(), metadata, data, version: '1.0' }; try { fs.writeFileSync(filepath, JSON.stringify(record, null, 2), 'utf8'); logger.success('SAVE', `Candidate data saved: ${name}`, { filepath }); // Also append to daily log this._appendToDailyLog('candidates', record); return { success: true, filepath }; } catch (error) { logger.error('SAVE', `Failed to save candidate: ${name}`, error); return { success: false, error: error.message }; } } async savePrsData(name, type, data, metadata = {}) { const filename = this._sanitizeFilename(`${type}-${name}`) + '.json'; const filepath = path.join(this.categories.prs, filename); const record = { name, type, timestamp: new Date().toISOString(), metadata, data, version: '1.0' }; try { fs.writeFileSync(filepath, JSON.stringify(record, null, 2), 'utf8'); logger.success('SAVE', `PRS data saved: ${name} (${type})`, { filepath }); this._appendToDailyLog('prs', record); return { success: true, filepath }; } catch (error) { logger.error('SAVE', `Failed to save PRS: ${name}`, error); return { success: false, error: error.message }; } } async saveAnalytics(eventType, data) { const filename = `${eventType}-${Date.now()}.json`; const filepath = path.join(this.categories.analytics, filename); const record = { eventType, timestamp: new Date().toISOString(), data }; try { fs.writeFileSync(filepath, JSON.stringify(record, null, 2), 'utf8'); // Also append to aggregated analytics this._appendToAnalyticsLog(record); return { success: true, filepath }; } catch (error) { logger.error('SAVE', 'Failed to save analytics', error); return { success: false, error: error.message }; } } // ======================================================================== // READ METHODS // ======================================================================== async getCandidateData(name) { const filename = this._sanitizeFilename(name) + '.json'; const filepath = path.join(this.categories.candidates, filename); try { if (!fs.existsSync(filepath)) { return { found: false, error: 'Not found in storage' }; } const data = JSON.parse(fs.readFileSync(filepath, 'utf8')); logger.info('READ', `Candidate data retrieved: ${name}`); return { found: true, data }; } catch (error) { logger.error('READ', `Failed to read candidate: ${name}`, error); return { found: false, error: error.message }; } } async getPrsData(name, type) { const filename = this._sanitizeFilename(`${type}-${name}`) + '.json'; const filepath = path.join(this.categories.prs, filename); try { if (!fs.existsSync(filepath)) { return { found: false, error: 'Not found in storage' }; } const data = JSON.parse(fs.readFileSync(filepath, 'utf8')); logger.info('READ', `PRS data retrieved: ${name} (${type})`); return { found: true, data }; } catch (error) { logger.error('READ', `Failed to read PRS: ${name}`, error); return { found: false, error: error.message }; } } // ======================================================================== // SEARCH & QUERY // ======================================================================== searchCandidates(query) { try { const files = fs.readdirSync(this.categories.candidates); const results = []; const lowerQuery = query.toLowerCase(); files.forEach(file => { if (!file.endsWith('.json')) return; const filepath = path.join(this.categories.candidates, file); try { const data = JSON.parse(fs.readFileSync(filepath, 'utf8')); if (data.name.toLowerCase().includes(lowerQuery)) { results.push({ name: data.name, timestamp: data.timestamp, file: file }); } } catch (err) { // Skip invalid files } }); logger.info('SEARCH', `Found ${results.length} candidates`, { query }); return results; } catch (error) { logger.error('SEARCH', 'Search failed', error); return []; } } // ======================================================================== // DAILY LOGS (Append-only logs) // ======================================================================== _appendToDailyLog(category, record) { const date = new Date().toISOString().split('T')[0]; const filename = `${date}-${category}.jsonl`; // JSON Lines format const filepath = path.join(this.categories[category], filename); try { const logLine = JSON.stringify(record) + '\n'; fs.appendFileSync(filepath, logLine, 'utf8'); } catch (error) { logger.error('LOG', 'Failed to append to daily log', error); } } _appendToAnalyticsLog(record) { const date = new Date().toISOString().split('T')[0]; const filename = `${date}-analytics.jsonl`; const filepath = path.join(this.categories.analytics, filename); try { const logLine = JSON.stringify(record) + '\n'; fs.appendFileSync(filepath, logLine, 'utf8'); } catch (error) { logger.error('LOG', 'Failed to append to analytics log', error); } } // ======================================================================== // ANALYTICS & REPORTING // ======================================================================== getAnalyticsSummary(days = 7) { try { const files = fs.readdirSync(this.categories.analytics); const cutoffDate = Date.now() - (days * 24 * 60 * 60 * 1000); const summary = { totalEvents: 0, eventsByType: {}, dateRange: { from: new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString(), to: new Date().toISOString() } }; files.forEach(file => { if (!file.endsWith('.jsonl')) return; const filepath = path.join(this.categories.analytics, file); const stats = fs.statSync(filepath); if (stats.mtimeMs < cutoffDate) return; const content = fs.readFileSync(filepath, 'utf8'); const lines = content.split('\n').filter(Boolean); lines.forEach(line => { try { const event = JSON.parse(line); summary.totalEvents++; summary.eventsByType[event.eventType] = (summary.eventsByType[event.eventType] || 0) + 1; } catch (err) { // Skip invalid lines } }); }); return summary; } catch (error) { logger.error('ANALYTICS', 'Failed to generate summary', error); return { error: error.message }; } } // ======================================================================== // UTILITIES // ======================================================================== _sanitizeFilename(name) { return name .toLowerCase() .replace(/[^a-z0-9-]/g, '-') .replace(/-+/g, '-') .replace(/^-|-$/g, ''); } getStats() { const stats = {}; Object.entries(this.categories).forEach(([category, dir]) => { try { const files = fs.readdirSync(dir); let totalSize = 0; files.forEach(file => { const filepath = path.join(dir, file); totalSize += fs.statSync(filepath).size; }); stats[category] = { files: files.length, size: `${(totalSize / 1024 / 1024).toFixed(2)} MB` }; } catch (error) { stats[category] = { error: error.message }; } }); return stats; } clearCategory(category) { if (!this.categories[category]) { throw new Error(`Invalid category: ${category}`); } try { const files = fs.readdirSync(this.categories[category]); let deleted = 0; files.forEach(file => { const filepath = path.join(this.categories[category], file); fs.unlinkSync(filepath); deleted++; }); logger.info('CLEAR', `Cleared ${deleted} files from ${category}`); return { success: true, deleted }; } catch (error) { logger.error('CLEAR', `Failed to clear ${category}`, error); return { success: false, error: error.message }; } } // Export all data as backup async createBackup() { const backupDir = path.join(this.storageDir, 'backups'); if (!fs.existsSync(backupDir)) { fs.mkdirSync(backupDir, { recursive: true }); } const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); const backupFile = path.join(backupDir, `backup-${timestamp}.tar`); // For simplicity, create a JSON backup const backup = { timestamp: new Date().toISOString(), version: '1.0', data: {} }; Object.entries(this.categories).forEach(([category, dir]) => { backup.data[category] = []; const files = fs.readdirSync(dir); files.forEach(file => { if (file.endsWith('.json')) { const filepath = path.join(dir, file); const content = JSON.parse(fs.readFileSync(filepath, 'utf8')); backup.data[category].push(content); } }); }); const backupPath = path.join(backupDir, `backup-${timestamp}.json`); fs.writeFileSync(backupPath, JSON.stringify(backup, null, 2), 'utf8'); logger.success('BACKUP', `Backup created: ${backupPath}`); return { success: true, filepath: backupPath }; } // ======================================================================== // CLEANUP METHODS // ======================================================================== // Cleanup old files in storage categories based on retention policies cleanupOldFiles(retentionConfig = {}) { const results = { totalDeleted: 0, categories: {} }; Object.entries(this.categories).forEach(([category, dir]) => { try { const retentionValue = retentionConfig[category] || 30; // Default 30 days let retentionMs; // Check if retention is in hours (for storage) or days (for logs) if (category === 'logs') { retentionMs = retentionValue * 24 * 60 * 60 * 1000; // days to ms } else { retentionMs = retentionValue * 60 * 60 * 1000; // hours to ms } const cutoffDate = Date.now() - retentionMs; const files = fs.readdirSync(dir); let deleted = 0; files.forEach(file => { const filepath = path.join(dir, file); const stats = fs.statSync(filepath); if (stats.mtimeMs < cutoffDate) { fs.unlinkSync(filepath); deleted++; } }); results.categories[category] = { deleted, retentionValue, retentionUnit: category === 'logs' ? 'days' : 'hours', totalFiles: files.length }; if (deleted > 0) { logger.info('CLEANUP', `Cleaned ${deleted} old files from ${category} (${retentionValue} ${category === 'logs' ? 'days' : 'hours'} retention)`); } results.totalDeleted += deleted; } catch (error) { logger.error('CLEANUP', `Failed to cleanup ${category}`, error); results.categories[category] = { error: error.message }; } }); return results; } // Get cleanup stats (preview what would be deleted) getCleanupStats(retentionConfig = {}) { const stats = { totalFiles: 0, filesToDelete: 0, categories: {} }; Object.entries(this.categories).forEach(([category, dir]) => { try { const retentionValue = retentionConfig[category] || 30; // Default 30 days let retentionMs; // Check if retention is in hours (for storage) or days (for logs) if (category === 'logs') { retentionMs = retentionValue * 24 * 60 * 60 * 1000; // days to ms } else { retentionMs = retentionValue * 60 * 60 * 1000; // hours to ms } const cutoffDate = Date.now() - retentionMs; const files = fs.readdirSync(dir); let toDelete = 0; files.forEach(file => { const filepath = path.join(dir, file); const stats = fs.statSync(filepath); if (stats.mtimeMs < cutoffDate) { toDelete++; } }); stats.categories[category] = { totalFiles: files.length, filesToDelete: toDelete, retentionValue, retentionUnit: category === 'logs' ? 'days' : 'hours' }; stats.totalFiles += files.length; stats.filesToDelete += toDelete; } catch (error) { stats.categories[category] = { error: error.message }; } }); return stats; } } export default new FileStorage();