/ src / utils / compliance-validator.js
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 };