/ src / sync-daemon.js
sync-daemon.js
  1  #!/usr/bin/env node
  2  
  3  /**
  4   * Unified Sync Daemon
  5   *
  6   * Runs all periodic sync operations in one place:
  7   * - Email events (opens, clicks, bounces) from Cloudflare Worker
  8   * - Unsubscribes from Cloudflare Worker
  9   * - Inbound SMS from Twilio API
 10   *
 11   * This replaces the need for 3 separate cron jobs.
 12   */
 13  
 14  import { syncEmailEvents } from './utils/sync-email-events.js';
 15  import { syncUnsubscribes } from './utils/sync-unsubscribes.js';
 16  import { pollInboundSMS } from './inbound/sms.js';
 17  import Logger from './utils/logger.js';
 18  
 19  const logger = new Logger('SyncDaemon');
 20  
 21  // Configuration
 22  const SYNC_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
 23  const INITIAL_DELAY_MS = 5 * 1000; // 5 seconds after startup
 24  
 25  // Track last successful sync times
 26  const lastSync = {
 27    emailEvents: null,
 28    unsubscribes: null,
 29    inboundSMS: null,
 30  };
 31  
 32  // Track if we're currently syncing (prevent overlaps)
 33  const syncing = {
 34    emailEvents: false,
 35    unsubscribes: false,
 36    inboundSMS: false,
 37  };
 38  
 39  /**
 40   * Sync email events from Cloudflare Worker
 41   */
 42  async function syncEmailEventsTask() {
 43    if (syncing.emailEvents) {
 44      logger.warn('Email events sync already in progress, skipping...');
 45      return;
 46    }
 47  
 48    syncing.emailEvents = true;
 49    try {
 50      logger.info('Syncing email events...');
 51      const result = await syncEmailEvents();
 52      lastSync.emailEvents = new Date();
 53      logger.success(
 54        `Email events synced: ${result.processed} events (${result.opened} opened, ${result.clicked} clicked)`
 55      );
 56    } catch (error) {
 57      logger.error('Failed to sync email events:', error);
 58    } finally {
 59      syncing.emailEvents = false;
 60    }
 61  }
 62  
 63  /**
 64   * Sync unsubscribes from Cloudflare Worker
 65   */
 66  async function syncUnsubscribesTask() {
 67    if (syncing.unsubscribes) {
 68      logger.warn('Unsubscribes sync already in progress, skipping...');
 69      return;
 70    }
 71  
 72    syncing.unsubscribes = true;
 73    try {
 74      logger.info('Syncing unsubscribes...');
 75      const result = await syncUnsubscribes();
 76      lastSync.unsubscribes = new Date();
 77      logger.success(`Unsubscribes synced: ${result.synced} new unsubscribes`);
 78    } catch (error) {
 79      logger.error('Failed to sync unsubscribes:', error);
 80    } finally {
 81      syncing.unsubscribes = false;
 82    }
 83  }
 84  
 85  /**
 86   * Poll inbound SMS from Twilio
 87   */
 88  async function pollInboundSMSTask() {
 89    if (syncing.inboundSMS) {
 90      logger.warn('SMS polling already in progress, skipping...');
 91      return;
 92    }
 93  
 94    syncing.inboundSMS = true;
 95    try {
 96      logger.info('Polling inbound SMS...');
 97      const result = await pollInboundSMS();
 98      lastSync.inboundSMS = new Date();
 99      logger.success(`SMS polled: ${result.stored} new messages`);
100    } catch (error) {
101      logger.error('Failed to poll SMS:', error);
102    } finally {
103      syncing.inboundSMS = false;
104    }
105  }
106  
107  /**
108   * Run all sync tasks in parallel
109   */
110  async function runAllSyncs() {
111    logger.info('Starting sync cycle...');
112    const start = Date.now();
113  
114    // Run all syncs in parallel (they're independent)
115    await Promise.all([syncEmailEventsTask(), syncUnsubscribesTask(), pollInboundSMSTask()]);
116  
117    const duration = ((Date.now() - start) / 1000).toFixed(2);
118    logger.success(`Sync cycle completed in ${duration}s`);
119  }
120  
121  /**
122   * Display status information
123   */
124  function displayStatus() {
125    console.log('\n=== Sync Daemon Status ===');
126    console.log(`Interval: Every ${SYNC_INTERVAL_MS / 1000 / 60} minutes`);
127    console.log(`\nLast Syncs:`);
128    console.log(
129      `  Email Events: ${lastSync.emailEvents ? lastSync.emailEvents.toLocaleString() : 'Never'}`
130    );
131    console.log(
132      `  Unsubscribes: ${lastSync.unsubscribes ? lastSync.unsubscribes.toLocaleString() : 'Never'}`
133    );
134    console.log(
135      `  Inbound SMS:  ${lastSync.inboundSMS ? lastSync.inboundSMS.toLocaleString() : 'Never'}`
136    );
137    console.log(`\nNext sync: ${new Date(Date.now() + SYNC_INTERVAL_MS).toLocaleString()}`);
138    console.log('=========================\n');
139  }
140  
141  /**
142   * Main daemon loop
143   */
144  // eslint-disable-next-line require-await -- Schedules async work with setTimeout
145  async function startDaemon() {
146    logger.success('🚀 Sync Daemon started');
147    logger.info(`Syncing every ${SYNC_INTERVAL_MS / 1000 / 60} minutes`);
148    logger.info(`First sync in ${INITIAL_DELAY_MS / 1000} seconds...`);
149  
150    // Initial sync after delay
151    setTimeout(async () => {
152      await runAllSyncs();
153      displayStatus();
154  
155      // Set up recurring sync
156      setInterval(async () => {
157        await runAllSyncs();
158        displayStatus();
159      }, SYNC_INTERVAL_MS);
160    }, INITIAL_DELAY_MS);
161  
162    // Display status every 30 minutes when idle
163    setInterval(
164      () => {
165        if (!syncing.emailEvents && !syncing.unsubscribes && !syncing.inboundSMS) {
166          displayStatus();
167        }
168      },
169      30 * 60 * 1000
170    );
171  }
172  
173  /**
174   * Graceful shutdown
175   */
176  function shutdown(signal) {
177    logger.warn(`\nReceived ${signal}, shutting down gracefully...`);
178  
179    // Wait for any in-progress syncs to complete
180    const checkInterval = setInterval(() => {
181      if (!syncing.emailEvents && !syncing.unsubscribes && !syncing.inboundSMS) {
182        clearInterval(checkInterval);
183        logger.success('Shutdown complete');
184        process.exit(0);
185      } else {
186        logger.info('Waiting for in-progress syncs to complete...');
187      }
188    }, 1000);
189  
190    // Force exit after 30 seconds
191    setTimeout(() => {
192      logger.error('Forced shutdown after 30s timeout');
193      process.exit(1);
194    }, 30000);
195  }
196  
197  // Handle shutdown signals
198  process.on('SIGTERM', () => shutdown('SIGTERM'));
199  process.on('SIGINT', () => shutdown('SIGINT'));
200  
201  // Handle uncaught errors
202  process.on('uncaughtException', error => {
203    logger.error('Uncaught exception:', error);
204    process.exit(1);
205  });
206  
207  process.on('unhandledRejection', (reason, promise) => {
208    logger.error('Unhandled rejection at:', promise, 'reason:', reason);
209    process.exit(1);
210  });
211  
212  // Start the daemon
213  startDaemon();