compliance-validator.js
1 /** 2 * Compliance Validator 3 * Pre-storage validation of proposal text at generation time. 4 * Checks compliance requirements before storing outreach entries. 5 * Existing per-channel compliance (sms.js, email.js) handles send-time enforcement. 6 */ 7 8 import { readFileSync } from 'fs'; 9 import { join, dirname } from 'path'; 10 import { fileURLToPath } from 'url'; 11 import Logger from './logger.js'; 12 import { requiresPhysicalAddress } from './email-compliance.js'; 13 import { requiresSenderIdInBody, getSmsEnderId } from './sms-compliance.js'; 14 15 const __filename = fileURLToPath(import.meta.url); 16 const __dirname = dirname(__filename); 17 const projectRoot = join(__dirname, '../..'); 18 19 const logger = new Logger('ComplianceValidator'); 20 21 // Load data-driven compliance rules 22 let requirements = {}; 23 let blockedChannels = { blocks: [] }; 24 25 try { 26 requirements = JSON.parse( 27 readFileSync(join(projectRoot, 'data/compliance/requirements.json'), 'utf-8') 28 ); 29 } catch (_) { 30 logger.warn('Could not load data/compliance/requirements.json'); 31 } 32 33 try { 34 blockedChannels = JSON.parse( 35 readFileSync(join(projectRoot, 'data/compliance/blocked-channels.json'), 'utf-8') 36 ); 37 } catch (_) { 38 logger.warn('Could not load data/compliance/blocked-channels.json'); 39 } 40 41 /** 42 * Validate a proposal before storage. 43 * Returns {ok, blocked, reason, modifiedText}. 44 * 45 * @param {string} proposalText - Generated proposal text 46 * @param {string} channel - 'sms' | 'email' 47 * @param {string} countryCode - ISO 3166-1 alpha-2 country code 48 * @returns {{ ok: boolean, blocked: boolean, reason: string|null, modifiedText: string|null }} 49 */ 50 export function validateCompliance(proposalText, channel, countryCode) { 51 if (!countryCode) return blocked('country_code is required for compliance validation'); 52 const cc = countryCode.toUpperCase(); 53 let text = proposalText; 54 55 // 1. Check blocked-channels.json 56 const isBlocked = (blockedChannels.blocks || []).some( 57 b => b.country === cc && b.channel === channel 58 ); 59 if (isBlocked) { 60 const block = blockedChannels.blocks.find(b => b.country === cc && b.channel === channel); 61 return blocked(`Channel blocked for ${cc}: ${block?.reason || 'no reason given'}`); 62 } 63 64 const countryReqs = requirements[cc] || {}; 65 const channelReqs = countryReqs[channel] || {}; 66 67 if (channel === 'email') { 68 // Physical address: validate env var is configured (email.js injects it at send time) 69 if (channelReqs.requiresPhysicalAddress || requiresPhysicalAddress(cc)) { 70 const addr = process.env.CAN_SPAM_PHYSICAL_ADDRESS; 71 if (!addr || !addr.trim()) { 72 return blocked( 73 `CAN-SPAM/Spam Act: CAN_SPAM_PHYSICAL_ADDRESS env var not set (required for ${cc})` 74 ); 75 } 76 } 77 78 // Subject line basic checks 79 const subjectMatch = text.match(/^Subject: (.+)/i); 80 if (subjectMatch) { 81 const subject = subjectMatch[1]; 82 if (/^(RE:|FWD:)/i.test(subject)) { 83 return blocked(`Subject line must not start with RE: or FWD: ("${subject}")`); 84 } 85 const allCapsWords = subject.split(/\s+/).filter(w => w.length > 2 && w === w.toUpperCase()); 86 if (allCapsWords.length > 3) { 87 logger.warn(`Subject has ${allCapsWords.length} ALL CAPS words — may trigger spam filters`); 88 } 89 } 90 } 91 92 if (channel === 'sms') { 93 // Sender ID: append if required and not already present 94 if (channelReqs.requiresSenderIdInBody || requiresSenderIdInBody(cc)) { 95 const senderId = getSmsEnderId(); 96 if (!text.includes(senderId)) { 97 text = `${text} ${senderId}`; 98 logger.info(`Appended sender ID for ${cc}: ${senderId}`); 99 } 100 } 101 102 // Character count warnings (informational, not blocking) 103 const maxSafe = ['US', 'CA'].includes(cc) ? 137 : 153; // leave room for opt-out 104 if (text.length > maxSafe) { 105 logger.warn( 106 `SMS for ${cc} is ${text.length} chars (safe max ${maxSafe}) — may require multiple segments` 107 ); 108 } 109 } 110 111 return { 112 ok: true, 113 blocked: false, 114 reason: null, 115 modifiedText: text !== proposalText ? text : null, 116 }; 117 } 118 119 function blocked(reason) { 120 return { ok: false, blocked: true, reason, modifiedText: null }; 121 } 122 123 export default { validateCompliance };