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 const cc = (countryCode || 'AU').toUpperCase(); 52 let text = proposalText; 53 54 // 1. Check blocked-channels.json 55 const isBlocked = (blockedChannels.blocks || []).some( 56 b => b.country === cc && b.channel === channel 57 ); 58 if (isBlocked) { 59 const block = blockedChannels.blocks.find(b => b.country === cc && b.channel === channel); 60 return blocked(`Channel blocked for ${cc}: ${block?.reason || 'no reason given'}`); 61 } 62 63 const countryReqs = requirements[cc] || {}; 64 const channelReqs = countryReqs[channel] || {}; 65 66 if (channel === 'email') { 67 // Physical address: validate env var is configured (email.js injects it at send time) 68 if (channelReqs.requiresPhysicalAddress || requiresPhysicalAddress(cc)) { 69 const addr = process.env.CAN_SPAM_PHYSICAL_ADDRESS; 70 if (!addr || !addr.trim()) { 71 return blocked( 72 `CAN-SPAM/Spam Act: CAN_SPAM_PHYSICAL_ADDRESS env var not set (required for ${cc})` 73 ); 74 } 75 } 76 77 // Subject line basic checks 78 const subjectMatch = text.match(/^Subject: (.+)/i); 79 if (subjectMatch) { 80 const subject = subjectMatch[1]; 81 if (/^(RE:|FWD:)/i.test(subject)) { 82 return blocked(`Subject line must not start with RE: or FWD: ("${subject}")`); 83 } 84 const allCapsWords = subject.split(/\s+/).filter(w => w.length > 2 && w === w.toUpperCase()); 85 if (allCapsWords.length > 3) { 86 logger.warn(`Subject has ${allCapsWords.length} ALL CAPS words — may trigger spam filters`); 87 } 88 } 89 } 90 91 if (channel === 'sms') { 92 // Sender ID: append if required and not already present 93 if (channelReqs.requiresSenderIdInBody || requiresSenderIdInBody(cc)) { 94 const senderId = getSmsEnderId(); 95 if (!text.includes(senderId)) { 96 text = `${text} ${senderId}`; 97 logger.info(`Appended sender ID for ${cc}: ${senderId}`); 98 } 99 } 100 101 // Character count warnings (informational, not blocking) 102 const maxSafe = ['US', 'CA'].includes(cc) ? 137 : 153; // leave room for opt-out 103 if (text.length > maxSafe) { 104 logger.warn( 105 `SMS for ${cc} is ${text.length} chars (safe max ${maxSafe}) — may require multiple segments` 106 ); 107 } 108 } 109 110 return { 111 ok: true, 112 blocked: false, 113 reason: null, 114 modifiedText: text !== proposalText ? text : null, 115 }; 116 } 117 118 function blocked(reason) { 119 return { ok: false, blocked: true, reason, modifiedText: null }; 120 } 121 122 export default { validateCompliance };