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.API_WORKER_URL; 29 const workerSecret = process.env.API_WORKER_SECRET; 30 31 if (!workerUrl || !workerSecret) { 32 logger.warn('API_WORKER_URL or API_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 }