/ src / cron / poll-free-scans.js
poll-free-scans.js
  1  #!/usr/bin/env node
  2  
  3  /**
  4   * Poll Free Scans from Cloudflare Worker
  5   *
  6   * Fetches new free scan results from the auditandfix-api CF Worker KV queue,
  7   * archives them into the local free_scans table, and removes processed
  8   * records from the KV queue.
  9   *
 10   * Follows the same pattern as poll-purchases.js.
 11   * Reuses archiveScans() from src/api/free-score-api.js for the DB write.
 12   */
 13  
 14  import axios from 'axios';
 15  import { getOne } from './../utils/db.js';
 16  import Logger from '../utils/logger.js';
 17  import { archiveScans } from '../api/free-score-api.js';
 18  import { enrollScanEmailSequence } from './send-scan-email-sequence.js';
 19  import '../utils/load-env.js';
 20  
 21  const logger = new Logger('PollFreeScans');
 22  
 23  /**
 24   * Poll the CF Worker for new free scan records and archive them to the DB.
 25   * @returns {{ processed: number, inserted: number, failed: number }}
 26   */
 27  export async function pollFreeScans() {
 28    const workerUrl = process.env.AUDITANDFIX_WORKER_URL;
 29    const workerSecret = process.env.AUDITANDFIX_WORKER_SECRET;
 30  
 31    if (!workerUrl || !workerSecret) {
 32      logger.warn('AUDITANDFIX_WORKER_URL or AUDITANDFIX_WORKER_SECRET not configured, skipping');
 33      return { processed: 0, inserted: 0, failed: 0 };
 34    }
 35  
 36    logger.info('Polling for new free scans...');
 37  
 38    let scans;
 39    try {
 40      const response = await axios.get(`${workerUrl}/scans/pending`, {
 41        headers: { 'X-Auth-Secret': workerSecret },
 42        timeout: 10000,
 43      });
 44      scans = response.data.scans || [];
 45    } catch (error) {
 46      logger.error('Failed to fetch free scans from CF Worker', error);
 47      return { processed: 0, inserted: 0, failed: 0 };
 48    }
 49  
 50    if (scans.length === 0) {
 51      logger.info('No new free scans');
 52      return { processed: 0, inserted: 0, failed: 0 };
 53    }
 54  
 55    logger.info(`Found ${scans.length} new free scan(s)`);
 56  
 57    let inserted = 0;
 58    let enrolled = 0;
 59    try {
 60      inserted = await archiveScans(scans);
 61  
 62      // Enrol opted-in scans into the post-scan nurture email sequence
 63      if (inserted > 0) {
 64        const optedIn = scans.filter(s => s.email && s.marketing_optin);
 65        for (const scan of optedIn) {
 66          try {
 67            // Look up the freshly-archived row to get the canonical data
 68            const row = await getOne('SELECT * FROM free_scans WHERE scan_id = $1', [scan.scan_id]);
 69            if (row) {
 70              const result = await enrollScanEmailSequence(row);
 71              if (result.enrolled) enrolled++;
 72            }
 73          } catch (enrollErr) {
 74            logger.warn(`Failed to enrol scan ${scan.scan_id} into email sequence: ${enrollErr.message}`);
 75          }
 76        }
 77        if (enrolled > 0) logger.info(`Enrolled ${enrolled} scan(s) into email sequence`);
 78      }
 79    } catch (error) {
 80      logger.error('Failed to archive free scans to DB', error);
 81      return { processed: scans.length, inserted: 0, failed: scans.length };
 82    }
 83  
 84    logger.info(`Archived ${inserted} new scan(s) (${scans.length - inserted} already existed)`);
 85  
 86    // Acknowledge all — even duplicates are safe to remove from KV
 87    const kvKeys = scans.map(s => s.kv_key).filter(Boolean);
 88    let failed = 0;
 89    await Promise.all(
 90      kvKeys.map(key =>
 91        axios
 92          .delete(`${workerUrl}/scans/${encodeURIComponent(key)}`, {
 93            headers: { 'X-Auth-Secret': workerSecret },
 94            timeout: 10000,
 95          })
 96          .catch(err => {
 97            logger.warn(`Failed to acknowledge ${key}: ${err.message}`);
 98            failed++;
 99          })
100      )
101    );
102  
103    logger.info(`Acknowledged ${kvKeys.length - failed}/${kvKeys.length} scan(s)`);
104    return { processed: scans.length, inserted, enrolled, failed };
105  }
106  
107  // CLI
108  if (import.meta.url === `file://${process.argv[1]}`) {
109    pollFreeScans()
110      .then(result => {
111        console.log('Poll result:', result);
112        process.exit(0);
113      })
114      .catch(error => {
115        logger.error('Poll failed:', error);
116        process.exit(1);
117      });
118  }