followup-generator.js
1 /** 2 * Follow-up Sequence Generator 3 * 4 * Creates multi-touch follow-up messages for sites that have been contacted 5 * but haven't replied. 8-touch cadence over 42 days: 6 * 7 * Touch 1 (Day 0): Initial outreach — already exists 8 * Touch 2 (Day 3): Different weakness + social proof 9 * Touch 3 (Day 7): ROI framing ("costing you $X/month") 10 * Touch 4 (Day 14): Case study + sample report link 11 * Touch 5 (Day 21): Quick win offer (free fix) 12 * Touch 6 (Day 28): Ad waste angle (if running ads) / competitor gap 13 * Touch 7 (Day 35): Authority / testimonial 14 * Touch 8 (Day 42): Breakup email ("closing the file") 15 * 16 * Each follow-up uses a different value angle to avoid repetition. 17 * SMS follow-ups are shorter than email follow-ups. 18 * 19 * Edge cases handled: 20 * - Don't follow up sites that received an inbound reply 21 * - Don't follow up opted-out contacts 22 * - Respect 3-day per-site cooldown between any sends 23 * - Don't generate follow-ups for sites already at max sequence step 24 */ 25 26 import { run, getOne, getAll } from '../utils/db.js'; 27 import { readFileSync, existsSync } from 'fs'; 28 import { join, dirname } from 'path'; 29 import { fileURLToPath } from 'url'; 30 import Logger from '../utils/logger.js'; 31 import { spin } from '../utils/spintax.js'; 32 import { computeGrade } from '../score.js'; 33 import { getCountryByCode } from '../config/countries.js'; 34 import { getScoreDataWithFallback } from '../utils/score-storage.js'; 35 import { getContactsDataWithFallback } from '../utils/contacts-storage.js'; 36 import { checkFixApplied } from '../utils/fix-checker.js'; 37 38 const __filename = fileURLToPath(import.meta.url); 39 const __dirname = dirname(__filename); 40 const projectRoot = join(__dirname, '../..'); 41 42 const logger = new Logger('FollowupGenerator'); 43 44 // Touch cadence: days after initial outreach for each follow-up 45 const TOUCH_CADENCE = { 46 2: 3, // Touch 2: 3 days after initial 47 3: 7, // Touch 3: 7 days after initial 48 4: 14, // Touch 4: 14 days after initial 49 5: 21, // Touch 5: 21 days — quick win offer 50 6: 28, // Touch 6: 28 days — ad waste / competitor gap 51 7: 35, // Touch 7: 35 days — authority / testimonial 52 8: 42, // Touch 8: 42 days — breakup 53 }; 54 55 const MAX_SEQUENCE_STEP = 8; 56 57 /** 58 * Get sites eligible for follow-up generation. 59 * 60 * Criteria: 61 * - status = 'outreach_sent' 62 * - next_followup_at <= now (or next_followup_at IS NULL for first follow-up scheduling) 63 * - No inbound reply received 64 * - Not opted out 65 * - Current max sequence_step < MAX_SEQUENCE_STEP 66 * - conversation_status not in closed states 67 * 68 * @param {number} limit - Max sites to process 69 * @returns {Promise<Array>} Sites eligible for follow-up 70 */ 71 export async function getFollowupEligibleSites(limit = 10) { 72 const closedStatuses = ['not_interested', 'closed', 'unsubscribed', 'paid', 'report_delivered']; 73 74 const sites = await getAll( 75 `SELECT 76 s.id, 77 s.domain, 78 s.landing_page_url, 79 s.keyword, 80 s.score, 81 s.grade, 82 s.country_code, 83 s.city, 84 s.state, 85 s.last_outreach_at, 86 s.next_followup_at, 87 s.campaign_tag, 88 s.free_fix_before_state, 89 -- Get the max sequence step already generated for this site 90 (SELECT MAX(m.sequence_step) FROM messages m 91 WHERE m.site_id = s.id AND m.direction = 'outbound' 92 AND m.sequence_step IS NOT NULL) as max_step, 93 -- Get the last sent outbound message timestamp 94 (SELECT MAX(m.sent_at) FROM messages m 95 WHERE m.site_id = s.id AND m.direction = 'outbound' 96 AND m.delivery_status IN ('sent', 'delivered')) as last_sent_at 97 FROM sites s 98 WHERE s.status = 'outreach_sent' 99 -- Due for follow-up: next_followup_at has passed 100 AND s.next_followup_at IS NOT NULL 101 AND s.next_followup_at <= NOW() 102 -- No inbound reply received (no messages with direction='inbound') 103 AND NOT EXISTS ( 104 SELECT 1 FROM messages m 105 WHERE m.site_id = s.id 106 AND m.direction = 'inbound' 107 ) 108 -- Not opted out (check both opt_outs and unsubscribed_emails) 109 AND NOT EXISTS ( 110 SELECT 1 FROM opt_outs o 111 JOIN messages m ON ( 112 (o.phone = m.contact_uri AND o.method = 'sms') 113 OR (o.email = m.contact_uri AND o.method = 'email') 114 ) 115 WHERE m.site_id = s.id AND m.direction = 'outbound' 116 ) 117 -- Not in a closed conversation state 118 AND (s.conversation_status IS NULL 119 OR s.conversation_status != ALL($1::text[])) 120 -- Haven't already generated all 8 touches 121 AND (SELECT COALESCE(MAX(m2.sequence_step), 1) FROM messages m2 122 WHERE m2.site_id = s.id AND m2.direction = 'outbound' 123 AND m2.sequence_step IS NOT NULL) < $2 124 -- Respect 3-day cooldown since last actual send 125 AND (s.last_outreach_at IS NULL 126 OR s.last_outreach_at < NOW() - INTERVAL '3 days') 127 -- Must have at least one successfully delivered email/sms to follow up on 128 AND EXISTS ( 129 SELECT 1 FROM messages m3 130 WHERE m3.site_id = s.id AND m3.direction = 'outbound' 131 AND m3.delivery_status IN ('sent', 'delivered') 132 AND m3.contact_method IN ('email', 'sms') 133 ) 134 ORDER BY s.next_followup_at ASC 135 LIMIT $3`, 136 [closedStatuses, MAX_SEQUENCE_STEP, limit] 137 ); 138 139 return sites; 140 } 141 142 /** 143 * Load follow-up templates for a country/channel combination. 144 * Returns null if country-specific templates don't exist (no AU fallback — 145 * each country must have its own templates with correct spelling/tone). 146 * 147 * @param {string} countryCode - ISO country code 148 * @param {string} channel - 'email' or 'sms' 149 * @returns {Object|null} Templates object with followup fields 150 */ 151 /** 152 * Campaign-specific template file prefix map (DR-128). 153 * 'freefix' → 'freefix-followup-{channel}.json' 154 * 'standard'/null → 'followup-{channel}.json' (original) 155 */ 156 const FOLLOWUP_CAMPAIGN_PREFIXES = { freefix: 'freefix-', review_acquisition: 'review-campaign-' }; 157 158 function loadFollowupTemplates(countryCode, channel, campaignTag = null) { 159 const templatesDir = join(projectRoot, 'data', 'templates'); 160 const prefix = FOLLOWUP_CAMPAIGN_PREFIXES[campaignTag] || ''; // eslint-disable-line security/detect-object-injection 161 const filename = `${prefix}followup-${channel}.json`; 162 const paths = [ 163 join(templatesDir, countryCode, filename), 164 ]; 165 166 // Fall back to standard templates if campaign-specific not found 167 if (prefix) { 168 paths.push(join(templatesDir, countryCode, `followup-${channel}.json`)); 169 } 170 171 for (const p of paths) { 172 if (existsSync(p)) { 173 try { 174 return JSON.parse(readFileSync(p, 'utf8')); 175 } catch (e) { 176 logger.warn(`Failed to parse template ${p}: ${e.message}`); 177 } 178 } 179 } 180 return null; 181 } 182 183 /** 184 * Extract weaknesses from score_json for follow-up messaging. 185 * Returns weaknesses NOT already used in the initial outreach (different angle). 186 * 187 * @param {Object} scoreJson - Parsed score_json 188 * @param {number} skipCount - Number of top weaknesses to skip (already used) 189 * @returns {Array} Array of {factor, score, reasoning} 190 */ 191 function extractWeaknesses(scoreJson, skipCount = 1) { 192 if (!scoreJson?.factor_scores) return []; 193 return Object.entries(scoreJson.factor_scores) 194 .filter(([, f]) => f.score < 5) 195 .sort((a, b) => a[1].score - b[1].score) 196 .slice(skipCount) // Skip the ones already used in initial outreach 197 .map(([name, f]) => ({ 198 factor: name.replace(/_/g, ' '), 199 score: f.score, 200 reasoning: f.reasoning, 201 })) 202 .slice(0, 3); 203 } 204 205 /** 206 * Generate follow-up messages for a single site. 207 * 208 * @param {Object} site - Site record from getFollowupEligibleSites 209 * @returns {Promise<Object>} Result with generated messages count 210 */ 211 export async function generateFollowupsForSite(site) { 212 const nextStep = (site.max_step || 1) + 1; 213 if (nextStep > MAX_SEQUENCE_STEP) { 214 return { generated: 0, reason: 'max_step_reached' }; 215 } 216 217 const scoreJson = getScoreDataWithFallback(site.id, site) ?? {}; 218 const contactsJson = getContactsDataWithFallback(site.id, site) ?? {}; 219 const businessName = contactsJson.business_name || site.domain; 220 const grade = site.grade || computeGrade(site.score); 221 const country = getCountryByCode(site.country_code); 222 const pricing = country 223 ? `${country.currencySymbol}${Math.round(country.priceUsd / 100)}` 224 : '$297'; 225 226 // Get existing outbound contact methods for this site (follow up on same channels) 227 const existingChannels = await getAll( 228 `SELECT DISTINCT contact_method, contact_uri 229 FROM messages 230 WHERE site_id = $1 AND direction = 'outbound' 231 AND delivery_status IN ('sent', 'delivered') 232 AND contact_method IN ('email', 'sms') 233 ORDER BY sent_at DESC`, 234 [site.id] 235 ); 236 237 if (existingChannels.length === 0) { 238 return { generated: 0, reason: 'no_sent_channels' }; 239 } 240 241 // For freefix campaign: check if the prospect applied the suggested fix (DR-128) 242 let fixCheckResult = null; 243 if (site.campaign_tag === 'freefix' && site.free_fix_before_state && nextStep >= 2 && nextStep <= 7) { 244 try { 245 fixCheckResult = await checkFixApplied(site); 246 logger.info(`Fix check for site ${site.id}: applied=${fixCheckResult.applied}`); 247 } catch (err) { 248 logger.warn(`Fix check failed for site ${site.id}: ${err.message}`); 249 } 250 } 251 252 let generated = 0; 253 254 for (const { contact_method: channel, contact_uri: contactUri } of existingChannels) { 255 // Check if this follow-up step already exists for this channel 256 const exists = await getOne( 257 `SELECT 1 FROM messages 258 WHERE site_id = $1 AND direction = 'outbound' 259 AND contact_method = $2 AND contact_uri = $3 260 AND sequence_step = $4`, 261 [site.id, channel, contactUri, nextStep] 262 ); 263 264 if (exists) continue; 265 266 // Check opt-out for this specific contact 267 const optedOut = await getOne( 268 `SELECT 1 FROM opt_outs 269 WHERE (phone = $1 AND method = 'sms') OR (email = $1 AND method = 'email')`, 270 [contactUri] 271 ); 272 273 if (optedOut) continue; 274 275 // Check unsubscribed emails 276 if (channel === 'email') { 277 const unsub = await getOne( 278 `SELECT 1 FROM unsubscribed_emails WHERE email = $1`, 279 [contactUri] 280 ); 281 if (unsub) continue; 282 } 283 284 // Generate the follow-up message 285 const message = await buildFollowupMessage({ 286 step: nextStep, 287 channel, 288 site, 289 scoreJson, 290 businessName, 291 grade, 292 pricing, 293 contactsJson, 294 fixCheckResult, 295 }); 296 297 if (!message) { 298 logger.warn(`No template for step ${nextStep} ${channel} — site ${site.id}`); 299 continue; 300 } 301 302 // Determine message_type for this step 303 const messageType = `followup${nextStep}`; 304 305 // GDPR check for approval status 306 let approvalStatus = 'pending'; 307 if (channel === 'email' && country?.requiresGDPRCheck) { 308 approvalStatus = 'gdpr_blocked'; 309 } 310 311 // Insert the follow-up message 312 try { 313 await run( 314 `INSERT INTO messages ( 315 site_id, direction, contact_method, contact_uri, 316 message_body, subject_line, approval_status, 317 message_type, sequence_step, campaign_tag 318 ) VALUES ($1, 'outbound', $2, $3, $4, $5, $6, $7, $8, $9)`, 319 [ 320 site.id, 321 channel, 322 contactUri, 323 message.body, 324 message.subject || null, 325 approvalStatus, 326 messageType, 327 nextStep, 328 site.campaign_tag || null, 329 ] 330 ); 331 generated++; 332 } catch (e) { 333 // Handle dedup constraint violations gracefully 334 if (e.message.includes('unique') || e.message.includes('duplicate')) { 335 logger.info(`Dedup: follow-up step ${nextStep} already exists for site ${site.id} ${channel}`); 336 } else { 337 logger.error(`Failed to insert follow-up for site ${site.id}: ${e.message}`); 338 } 339 } 340 } 341 342 // Update next_followup_at for the next touch 343 if (generated > 0) { 344 const nextNextStep = nextStep + 1; 345 if (nextNextStep <= MAX_SEQUENCE_STEP) { 346 const daysUntilNext = TOUCH_CADENCE[nextNextStep] - TOUCH_CADENCE[nextStep]; 347 await run( 348 `UPDATE sites 349 SET next_followup_at = NOW() + INTERVAL '${daysUntilNext} days', 350 updated_at = CURRENT_TIMESTAMP 351 WHERE id = $1`, 352 [site.id] 353 ); 354 } else { 355 // All follow-ups generated — clear next_followup_at 356 await run( 357 `UPDATE sites 358 SET next_followup_at = NULL, 359 updated_at = CURRENT_TIMESTAMP 360 WHERE id = $1`, 361 [site.id] 362 ); 363 } 364 } 365 366 return { generated, step: nextStep }; 367 } 368 369 /** 370 * Build a follow-up message using templates and spintax. 371 * 372 * @param {Object} params - Message parameters 373 * @returns {Object|null} {body, subject} or null if no template 374 */ 375 async function buildFollowupMessage({ step, channel, site, scoreJson, businessName, grade, pricing, contactsJson, fixCheckResult }) { 376 // Try loading templates 377 const templates = loadFollowupTemplates(site.country_code, channel, site.campaign_tag); 378 379 // Get the first contact name if available 380 const contacts = contactsJson?.contacts || []; 381 const contactName = contacts.find(c => c.name)?.name || null; 382 const firstname = contactName ? contactName.split(' ')[0] : null; 383 384 // Get weaknesses for different angles 385 const allWeaknesses = scoreJson?.factor_scores 386 ? Object.entries(scoreJson.factor_scores) 387 .filter(([, f]) => f.score < 5) 388 .sort((a, b) => a[1].score - b[1].score) 389 .map(([name, f]) => ({ 390 factor: name.replace(/_/g, ' '), 391 score: f.score, 392 reasoning: f.reasoning, 393 })) 394 : []; 395 396 const industry = scoreJson?.industry_classification || scoreJson?.overall_calculation?.industry_classification || 'business'; 397 const city = site.city || contactsJson?.city || ''; 398 399 if (!templates?.templates) { 400 logger.error(`No followup templates for ${site.country_code} ${channel} — skipping site ${site.id}`); 401 return null; 402 } 403 404 let stepTemplates = templates.templates.filter(t => t.step === step); 405 if (stepTemplates.length === 0) { 406 logger.error(`No followup templates for ${site.country_code} ${channel} step ${step} — skipping site ${site.id}`); 407 return null; 408 } 409 410 // For freefix campaign: filter by fix_check status if templates have the field (DR-128) 411 if (fixCheckResult && stepTemplates.some(t => t.fix_check)) { 412 const fixTag = fixCheckResult.applied ? 'applied' : 'not_applied'; 413 const filtered = stepTemplates.filter(t => t.fix_check === fixTag); 414 if (filtered.length > 0) stepTemplates = filtered; 415 // If no match, fall through to use all step templates 416 } 417 418 const template = stepTemplates[Math.floor(Math.random() * stepTemplates.length)]; 419 const vars = { 420 firstname, 421 domain: site.domain, 422 grade, 423 score: Math.round(site.score), 424 industry, 425 city, 426 businessName, 427 pricing, 428 siteId: site.id, 429 weakness: allWeaknesses[0]?.factor || 'homepage conversion', 430 weakness2: allWeaknesses[1]?.factor || 'call-to-action clarity', 431 reasoning: allWeaknesses[0]?.reasoning || '', 432 fixElement: fixCheckResult?.element || '', 433 brand_url_short: process.env.BRAND_DOMAIN, 434 brand_url: process.env.BRAND_URL, 435 persona_name: process.env.PERSONA_NAME || '', 436 persona_first_name: process.env.PERSONA_FIRST_NAME || '', 437 brand_name: process.env.BRAND_NAME || '', 438 gbp_review_link: process.env.GBP_REVIEW_LINK || '', 439 trustpilot_link: process.env.TRUSTPILOT_LINK || '', 440 }; 441 const body = resolveTemplate(template.body_spintax || template.body, vars); 442 const subject = template.subject_spintax 443 ? resolveTemplate(template.subject_spintax, vars) 444 : null; 445 return { body, subject }; 446 } 447 448 /** 449 * Resolve template variables and spintax. 450 */ 451 function resolveTemplate(template, vars) { 452 let text = template; 453 // Replace [var] placeholders 454 for (const [key, value] of Object.entries(vars)) { 455 if (value !== null && value !== undefined) { 456 text = text.replace(new RegExp(`\\[${key}\\]`, 'gi'), String(value)); 457 } 458 } 459 // Resolve [var|fallback] with fallback 460 text = text.replace(/\[(\w+)\|([^\]]+)\]/g, (match, key, fallback) => { 461 return vars[key] || fallback; 462 }); 463 // Resolve spintax 464 return spin(text); 465 } 466 467 /** 468 * Set next_followup_at on a site after the initial outreach is sent. 469 * Called from the outreach stage when a message is successfully delivered. 470 * 471 * @param {number} siteId - Site ID 472 * @returns {Promise<void>} 473 */ 474 export async function scheduleFirstFollowup(siteId) { 475 try { 476 // Only schedule if not already scheduled and site is in outreach_sent status 477 const site = await getOne( 478 `SELECT next_followup_at, status FROM sites WHERE id = $1`, 479 [siteId] 480 ); 481 482 if (!site) return; 483 484 // Schedule first follow-up (touch 2) for 3 days from now 485 // Only if next_followup_at is not already set 486 if (!site.next_followup_at) { 487 await run( 488 `UPDATE sites 489 SET next_followup_at = NOW() + INTERVAL '3 days', 490 updated_at = CURRENT_TIMESTAMP 491 WHERE id = $1 AND next_followup_at IS NULL`, 492 [siteId] 493 ); 494 } 495 } catch (e) { 496 logger.error(`Failed to schedule follow-up for site ${siteId}: ${e.message}`); 497 } 498 } 499 500 /** 501 * Run the follow-up generation stage. 502 * Finds sites due for follow-up and generates the next touch. 503 * 504 * @param {Object} options - Stage options 505 * @param {number} options.limit - Max sites to process per run 506 * @returns {Promise<Object>} Stage results 507 */ 508 export async function runFollowupGenerationStage(options = {}) { 509 const startTime = Date.now(); 510 const limit = options.limit || 20; 511 512 logger.info(`Starting Follow-up Generation (limit=${limit})...`); 513 514 const sites = await getFollowupEligibleSites(limit); 515 516 if (sites.length === 0) { 517 logger.info('No sites due for follow-up'); 518 return { 519 processed: 0, 520 generated: 0, 521 skipped: 0, 522 duration: Date.now() - startTime, 523 }; 524 } 525 526 logger.info(`Found ${sites.length} sites due for follow-up`); 527 528 let totalGenerated = 0; 529 let totalSkipped = 0; 530 531 for (const site of sites) { 532 try { 533 const result = await generateFollowupsForSite(site); 534 totalGenerated += result.generated; 535 if (result.generated === 0) totalSkipped++; 536 logger.info( 537 `Site ${site.id} (${site.domain}): generated ${result.generated} follow-up(s) ` + 538 `at step ${result.step || 'n/a'}${result.reason ? ` — ${result.reason}` : ''}` 539 ); 540 } catch (e) { 541 logger.error(`Failed to generate follow-up for site ${site.id}: ${e.message}`); 542 totalSkipped++; 543 } 544 } 545 546 const duration = Date.now() - startTime; 547 logger.info( 548 `Follow-up generation complete: ${totalGenerated} generated, ` + 549 `${totalSkipped} skipped in ${Math.round(duration / 1000)}s` 550 ); 551 552 return { 553 processed: sites.length, 554 generated: totalGenerated, 555 skipped: totalSkipped, 556 duration, 557 }; 558 }