/ src / cli / cron-manager.js
cron-manager.js
  1  #!/usr/bin/env node
  2  
  3  /**
  4   * Cron Job Manager CLI
  5   *
  6   * Easy management of scheduled tasks via database.
  7   *
  8   * Commands:
  9   *   list                - List all cron jobs with status
 10   *   enable <task_key>   - Enable a cron job
 11   *   disable <task_key>  - Disable a cron job
 12   *   add                 - Add a new cron job (interactive)
 13   *   edit <task_key>     - Edit an existing job (interactive)
 14   *   remove <task_key>   - Remove a cron job
 15   *   run <task_key>      - Manually trigger a job
 16   *   logs <task_key>     - View execution logs for a job
 17   *   stats               - Show cron job statistics
 18   */
 19  
 20  import { run, getOne, getAll } from '../utils/db.js';
 21  import Logger from '../utils/logger.js';
 22  import '../utils/load-env.js';
 23  
 24  const logger = new Logger('CronManager');
 25  
 26  /**
 27   * List all cron jobs
 28   */
 29  async function listJobs(filter = null) {
 30    const jobs =
 31      filter !== null
 32        ? await getAll(
 33            'SELECT * FROM ops.cron_jobs WHERE enabled = $1 ORDER BY interval_value, interval_unit',
 34            [filter]
 35          )
 36        : await getAll('SELECT * FROM ops.cron_jobs ORDER BY interval_value, interval_unit');
 37  
 38    if (jobs.length === 0) {
 39      logger.warn('No cron jobs found. Run migration and seed script first.');
 40      return;
 41    }
 42  
 43    console.log('\n╔════════════════════════════════════════════════════════════════════════════╗');
 44    console.log('║                            CRON JOBS MANAGER                               ║');
 45    console.log('╚════════════════════════════════════════════════════════════════════════════╝\n');
 46  
 47    // Group by interval
 48    const grouped = {};
 49    for (const job of jobs) {
 50      const interval = `${job.interval_value} ${job.interval_unit}`;
 51      if (!grouped[interval]) grouped[interval] = [];
 52      grouped[interval].push(job);
 53    }
 54  
 55    for (const [interval, intervalJobs] of Object.entries(grouped)) {
 56      console.log(`\n📅 Every ${interval}:`);
 57      console.log('─'.repeat(80));
 58  
 59      for (const job of intervalJobs) {
 60        const status = job.enabled ? '✅ Enabled' : '⏸️  Disabled';
 61        const lastRun = job.last_run_at ? new Date(job.last_run_at).toLocaleString() : 'Never run';
 62  
 63        console.log(`\n  ${status}  ${job.name}`);
 64        console.log(`  Key: ${job.task_key}`);
 65        console.log(`  Type: ${job.handler_type} | Handler: ${job.handler_value}`);
 66        console.log(`  Last run: ${lastRun}`);
 67        if (job.description) {
 68          console.log(`  Description: ${job.description}`);
 69        }
 70      }
 71    }
 72  
 73    console.log(`\n${'─'.repeat(80)}`);
 74    console.log(`Total: ${jobs.length} jobs`);
 75    const enabledCount = jobs.filter(j => j.enabled).length;
 76    console.log(`Status: ${enabledCount} enabled, ${jobs.length - enabledCount} disabled\n`);
 77  }
 78  
 79  /**
 80   * Enable a cron job
 81   */
 82  async function enableJob(taskKey) {
 83    const job = await getOne('SELECT * FROM ops.cron_jobs WHERE task_key = $1', [taskKey]);
 84  
 85    if (!job) {
 86      logger.error(`Job not found: ${taskKey}`);
 87      return 1;
 88    }
 89  
 90    if (job.enabled) {
 91      logger.warn(`Job already enabled: ${job.name}`);
 92      return 0;
 93    }
 94  
 95    await run('UPDATE ops.cron_jobs SET enabled = true WHERE task_key = $1', [taskKey]);
 96    logger.success(`✅ Enabled: ${job.name}`);
 97    return 0;
 98  }
 99  
100  /**
101   * Disable a cron job
102   */
103  async function disableJob(taskKey) {
104    const job = await getOne('SELECT * FROM ops.cron_jobs WHERE task_key = $1', [taskKey]);
105  
106    if (!job) {
107      logger.error(`Job not found: ${taskKey}`);
108      return 1;
109    }
110  
111    if (!job.enabled) {
112      logger.warn(`Job already disabled: ${job.name}`);
113      return 0;
114    }
115  
116    await run('UPDATE ops.cron_jobs SET enabled = false WHERE task_key = $1', [taskKey]);
117    logger.success(`⏸️  Disabled: ${job.name}`);
118    return 0;
119  }
120  
121  /**
122   * Remove a cron job
123   */
124  async function removeJob(taskKey) {
125    const job = await getOne('SELECT * FROM ops.cron_jobs WHERE task_key = $1', [taskKey]);
126  
127    if (!job) {
128      logger.error(`Job not found: ${taskKey}`);
129      return 1;
130    }
131  
132    await run('DELETE FROM ops.cron_jobs WHERE task_key = $1', [taskKey]);
133    logger.success(`🗑️  Removed: ${job.name}`);
134    return 0;
135  }
136  
137  /**
138   * View execution logs for a job
139   */
140  async function viewLogs(taskKey, limit = 10) {
141    const job = await getOne('SELECT * FROM ops.cron_jobs WHERE task_key = $1', [taskKey]);
142  
143    if (!job) {
144      logger.error(`Job not found: ${taskKey}`);
145      return 1;
146    }
147  
148    const logs = await getAll(
149      `SELECT * FROM ops.cron_job_logs
150       WHERE job_name = $1
151       ORDER BY started_at DESC
152       LIMIT $2`,
153      [job.name, limit]
154    );
155  
156    if (logs.length === 0) {
157      logger.warn(`No execution logs found for: ${job.name}`);
158      return 0;
159    }
160  
161    console.log(`\n📋 Execution Logs for: ${job.name}`);
162    console.log('─'.repeat(80));
163  
164    for (const log of logs) {
165      const duration = log.finished_at
166        ? ((new Date(log.finished_at) - new Date(log.started_at)) / 1000).toFixed(2)
167        : 'N/A';
168      const statusIcon = log.status === 'success' ? '✅' : log.status === 'failed' ? '❌' : '🔄';
169  
170      console.log(`\n  ${statusIcon} ${log.status.toUpperCase()}`);
171      console.log(`  Started: ${new Date(log.started_at).toLocaleString()}`);
172      console.log(`  Duration: ${duration}s`);
173      console.log(`  Items: ${log.items_processed || 0} processed, ${log.items_failed || 0} failed`);
174  
175      if (log.summary) {
176        console.log(`  Summary: ${log.summary}`);
177      }
178  
179      if (log.error_message) {
180        console.log(`  Error: ${log.error_message}`);
181      }
182    }
183  
184    console.log(`\n${'─'.repeat(80)}\n`);
185    return 0;
186  }
187  
188  /**
189   * Show statistics
190   */
191  async function showStats() {
192    const [totalRow, enabledRow, disabledRow, totalExecRow, successExecRow, failedExecRow] =
193      await Promise.all([
194        getOne('SELECT COUNT(*) as count FROM ops.cron_jobs'),
195        getOne('SELECT COUNT(*) as count FROM ops.cron_jobs WHERE enabled = true'),
196        getOne('SELECT COUNT(*) as count FROM ops.cron_jobs WHERE enabled = false'),
197        getOne('SELECT COUNT(*) as count FROM ops.cron_job_logs'),
198        getOne("SELECT COUNT(*) as count FROM ops.cron_job_logs WHERE status = 'success'"),
199        getOne("SELECT COUNT(*) as count FROM ops.cron_job_logs WHERE status = 'failed'"),
200      ]);
201  
202    const stats = {
203      total: Number(totalRow.count),
204      enabled: Number(enabledRow.count),
205      disabled: Number(disabledRow.count),
206      totalExecutions: Number(totalExecRow.count),
207      successfulExecutions: Number(successExecRow.count),
208      failedExecutions: Number(failedExecRow.count),
209    };
210  
211    const recentFailures = await getAll(
212      `SELECT job_name, COUNT(*) as count
213       FROM ops.cron_job_logs
214       WHERE status = 'failed' AND started_at >= NOW() - INTERVAL '7 days'
215       GROUP BY job_name
216       ORDER BY count DESC
217       LIMIT 5`
218    );
219  
220    console.log('\n╔════════════════════════════════════════════════════════════════════════════╗');
221    console.log('║                         CRON JOBS STATISTICS                               ║');
222    console.log('╚════════════════════════════════════════════════════════════════════════════╝\n');
223  
224    console.log('📊 Job Counts:');
225    console.log(`  Total Jobs: ${stats.total}`);
226    console.log(`  Enabled: ${stats.enabled} (${((stats.enabled / stats.total) * 100).toFixed(1)}%)`);
227    console.log(
228      `  Disabled: ${stats.disabled} (${((stats.disabled / stats.total) * 100).toFixed(1)}%)`
229    );
230  
231    console.log('\n📈 Execution History:');
232    console.log(`  Total Executions: ${stats.totalExecutions}`);
233    console.log(
234      `  Successful: ${stats.successfulExecutions} (${((stats.successfulExecutions / stats.totalExecutions) * 100).toFixed(1)}%)`
235    );
236    console.log(
237      `  Failed: ${stats.failedExecutions} (${((stats.failedExecutions / stats.totalExecutions) * 100).toFixed(1)}%)`
238    );
239  
240    if (recentFailures.length > 0) {
241      console.log('\n⚠️  Recent Failures (Last 7 Days):');
242      for (const failure of recentFailures) {
243        console.log(`  ${failure.job_name}: ${failure.count} failures`);
244      }
245    }
246  
247    console.log(`\n${'─'.repeat(80)}\n`);
248  }
249  
250  /**
251   * Add a new cron job (interactive)
252   */
253  async function addJob(options = {}) {
254    if (!options.name || !options.taskKey || !options.handlerValue) {
255      console.log('\n❌ Missing required options. Usage:');
256      console.log(
257        '  npm run cron:add -- --name "Job Name" --key jobKey --handler "npm run cmd" --interval 5 --unit minutes --type command'
258      );
259      console.log('\nRequired:');
260      console.log('  --name     Human-readable job name');
261      console.log('  --key      Unique programmatic identifier (camelCase)');
262      console.log('  --handler  Command to run or function name');
263      console.log('  --type     Handler type: "command" or "function"');
264      console.log('  --interval Interval value (number)');
265      console.log('  --unit     Interval unit: minutes, hours, days, weeks');
266      console.log('\nOptional:');
267      console.log('  --description  Job description');
268      console.log('  --enabled      Enable job (true/false, default: true)');
269      return 1;
270    }
271  
272    // Check if task_key already exists
273    const existing = await getOne(
274      'SELECT task_key FROM ops.cron_jobs WHERE task_key = $1',
275      [options.taskKey]
276    );
277    if (existing) {
278      logger.error(`Job already exists with task_key: ${options.taskKey}`);
279      return 1;
280    }
281  
282    // Insert new job
283    await run(
284      `INSERT INTO ops.cron_jobs (
285        name, task_key, description, handler_type, handler_value,
286        interval_value, interval_unit, enabled
287      ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
288      [
289        options.name,
290        options.taskKey,
291        options.description || null,
292        options.type || 'command',
293        options.handlerValue,
294        options.interval || 60,
295        options.unit || 'minutes',
296        options.enabled !== false ? true : false,
297      ]
298    );
299  
300    logger.success(`✅ Added new job: ${options.name}`);
301    return 0;
302  }
303  
304  /**
305   * Parse CLI arguments
306   */
307  function parseArgs(args) {
308    const parsed = {};
309    for (let i = 0; i < args.length; i++) {
310      const arg = args[i];
311      if (arg.startsWith('--')) {
312        const key = arg.slice(2);
313        const value = args[i + 1];
314        parsed[key] = value;
315        i++; // Skip next arg
316      }
317    }
318    return parsed;
319  }
320  
321  /**
322   * Main CLI handler
323   */
324  async function main() {
325    const [command, ...args] = process.argv.slice(2);
326  
327    if (!command) {
328      console.log('\n🔧 Cron Job Manager\n');
329      console.log('Usage: npm run cron:manage <command> [args]\n');
330      console.log('Commands:');
331      console.log('  list                 - List all cron jobs');
332      console.log('  list --enabled       - List enabled jobs only');
333      console.log('  list --disabled      - List disabled jobs only');
334      console.log('  enable <task_key>    - Enable a job');
335      console.log('  disable <task_key>   - Disable a job');
336      console.log('  remove <task_key>    - Remove a job');
337      console.log('  logs <task_key>      - View execution logs');
338      console.log('  stats                - Show statistics');
339      console.log('  add [options]        - Add a new job\n');
340      return 1;
341    }
342  
343    switch (command) {
344      case 'list': {
345        const filter = args.includes('--enabled') ? true : args.includes('--disabled') ? false : null;
346        await listJobs(filter);
347        return 0;
348      }
349  
350      case 'enable': {
351        const taskKey = args[0];
352        if (!taskKey) {
353          logger.error('Missing task_key. Usage: npm run cron:enable <task_key>');
354          return 1;
355        }
356        return await enableJob(taskKey);
357      }
358  
359      case 'disable': {
360        const taskKey = args[0];
361        if (!taskKey) {
362          logger.error('Missing task_key. Usage: npm run cron:disable <task_key>');
363          return 1;
364        }
365        return await disableJob(taskKey);
366      }
367  
368      case 'remove': {
369        const taskKey = args[0];
370        if (!taskKey) {
371          logger.error('Missing task_key. Usage: npm run cron:remove <task_key>');
372          return 1;
373        }
374        return await removeJob(taskKey);
375      }
376  
377      case 'logs': {
378        const taskKey = args[0];
379        if (!taskKey) {
380          logger.error('Missing task_key. Usage: npm run cron:logs <task_key>');
381          return 1;
382        }
383        return await viewLogs(taskKey);
384      }
385  
386      case 'stats':
387        await showStats();
388        return 0;
389  
390      case 'add': {
391        const options = parseArgs(args);
392        return await addJob(options);
393      }
394  
395      default:
396        logger.error(`Unknown command: ${command}`);
397        return 1;
398    }
399  }
400  
401  // Run if called directly
402  if (import.meta.url === `file://${process.argv[1]}`) {
403    main()
404      .then(code => process.exit(code))
405      .catch(error => {
406        console.error('Fatal error:', error);
407        process.exit(1);
408      });
409  }
410  
411  export { listJobs, enableJob, disableJob, removeJob, viewLogs, showStats, addJob, parseArgs };