← back to Crankd

lib/compliance.js

61 lines

'use strict';

const env = (k, d) => (process.env[k] ?? d);
const boolEnv = (k, d = false) => {
  const v = env(k);
  if (v == null) return d;
  return /^(1|true|yes|on)$/i.test(String(v));
};

// FCC Feb 2024 ruling: AI-generated voice calls under TCPA require prior express
// consent and the recipient must be told they're hearing an AI voice.
function aiDisclosurePreamble(owner = env('OWNER_LEGAL_NAME', 'the caller')) {
  return (
    `Heads up — this is an A I generated voice call from ${owner}. ` +
    `You opted in to receive this. Press one to continue, two to stop these forever, ` +
    `or hang up at any time.`
  );
}

function inQuietHours(nowDate = new Date(), tz = 'America/Los_Angeles') {
  const start = parseInt(env('QUIET_HOURS_START', '08'), 10);
  const end = parseInt(env('QUIET_HOURS_END', '21'), 10);
  const hour = parseInt(
    new Intl.DateTimeFormat('en-US', { hour: 'numeric', hour12: false, timeZone: tz })
      .format(nowDate),
    10
  );
  return hour < start || hour >= end;
}

// Pre-flight: returns { ok, reasons[] }. Every reason is a hard block — no soft pass.
function preflight({ recipient, opt_in_record, has_recording_consent, dnc_clean }) {
  const reasons = [];

  if (boolEnv('COMPLIANCE_STEVE_ONLY_MODE', true)) {
    const allowed = (env('STEVE_ALLOWED_NUMBERS', '') || '').split(',').map(s => s.trim()).filter(Boolean);
    if (!allowed.includes(recipient)) reasons.push('steve_only_mode_recipient_not_allowlisted');
  }

  if (boolEnv('COMPLIANCE_REQUIRE_OPT_IN', true)) {
    if (!opt_in_record || !opt_in_record.confirmed_at) reasons.push('no_opt_in_record');
  }

  if (boolEnv('COMPLIANCE_DNC_SCRUB_REQUIRED', true)) {
    if (!dnc_clean) reasons.push('dnc_scrub_not_run_or_failed');
  }

  if (boolEnv('COMPLIANCE_TIME_OF_DAY_GATE', true)) {
    if (inQuietHours()) reasons.push('inside_quiet_hours');
  }

  if (boolEnv('COMPLIANCE_BOTH_PARTY_CONSENT_FOR_RECORDING', true)) {
    // Recording is OPTIONAL per call; only enforce if a recording was requested.
    if (has_recording_consent === false) reasons.push('recording_requested_without_both_party_consent');
  }

  return { ok: reasons.length === 0, reasons };
}

module.exports = { aiDisclosurePreamble, inQuietHours, preflight, boolEnv };