compliance.js
1 /** 2 * TCPA Compliance Utilities 3 * Enforces business hours (8am-9pm) and opt-out management for SMS/Email outreach 4 */ 5 6 import Logger from './logger.js'; 7 import { detectTimezone } from './timezone-detector.js'; 8 import { run, getOne } from './db.js'; 9 import { addSuppression } from '../../../mmo-platform/src/suppression.js'; 10 11 const logger = new Logger('Compliance'); 12 13 /** 14 * Check if current time is within business hours (8am-9pm) for given timezone 15 * @param {string} timezone - IANA timezone (e.g., 'America/New_York', 'America/Los_Angeles', 'Australia/Sydney') 16 * @returns {boolean} - True if within business hours 17 */ 18 export function isBusinessHours(timezone = 'America/New_York') { 19 try { 20 const now = new Date(); 21 const formatter = new Intl.DateTimeFormat('en-US', { 22 timeZone: timezone, 23 hour: 'numeric', 24 hour12: false, 25 }); 26 27 const hour = parseInt(formatter.format(now), 10); 28 29 // TCPA requires 8am-9pm (21:00 in 24h format) 30 const withinHours = hour >= 8 && hour < 21; 31 32 if (!withinHours) { 33 logger.info(`Outside business hours in ${timezone}: ${hour}:00 (allowed: 8am-9pm)`); 34 } 35 36 return withinHours; 37 } catch (error) { 38 logger.error(`Invalid timezone: ${timezone}`, error); 39 // Default to blocking if timezone is invalid (safer) 40 return false; 41 } 42 } 43 44 /** 45 * Get recipient timezone from database based on site location 46 * @param {number} siteId - Site ID 47 * @returns {Promise<string>} - IANA timezone 48 */ 49 export async function getRecipientTimezone(siteId) { 50 const site = await getOne('SELECT city, country_code FROM sites WHERE id = $1', [siteId]); 51 52 if (!site) { 53 logger.warn(`Site ${siteId} not found, using default timezone`); 54 return 'America/New_York'; 55 } 56 57 return detectTimezone(site.city, site.country_code); 58 } 59 60 /** 61 * Add phone/email to opt-out list 62 * @param {string|null} phone - Phone number (E.164 format) 63 * @param {string|null} email - Email address 64 * @param {string} method - 'sms' or 'email' 65 * @returns {Promise<number>} - Row ID of opt-out record 66 */ 67 export async function addOptOut(phone, email, method) { 68 if (!phone && !email) { 69 throw new Error('Must provide phone or email for opt-out'); 70 } 71 72 if (!['sms', 'email'].includes(method)) { 73 throw new Error(`Invalid method: ${method} (must be 'sms' or 'email')`); 74 } 75 76 const result = await run( 77 `INSERT INTO opt_outs (phone, email, method, opted_out_at) 78 VALUES ($1, $2, $3, NOW()) 79 ON CONFLICT (phone, method) DO UPDATE SET opted_out_at = NOW() 80 RETURNING id`, 81 [phone, email, method] 82 ); 83 84 logger.info(`Added opt-out: ${phone || email} for ${method}`); 85 86 return result.lastInsertRowid || 0; 87 } 88 89 /** 90 * Check if phone/email is opted out 91 * @param {string|null} phone - Phone number (E.164 format) 92 * @param {string|null} email - Email address 93 * @param {string} method - 'sms' or 'email' 94 * @returns {Promise<boolean>} - True if opted out 95 */ 96 export async function isOptedOut(phone, email, method) { 97 if (!phone && !email) { 98 return false; 99 } 100 101 const optOut = await getOne( 102 `SELECT 1 FROM opt_outs 103 WHERE method = $1 104 AND (phone = $2 OR email = $3) 105 LIMIT 1`, 106 [method, phone, email] 107 ); 108 109 const optedOut = Boolean(optOut); 110 111 if (optedOut) { 112 logger.info(`Blocked: ${phone || email} is opted out of ${method}`); 113 } 114 115 return optedOut; 116 } 117 118 /** 119 * Check if SMS should be blocked (TCPA compliance) 120 * @param {string} phone - Phone number (E.164 format) 121 * @param {number} siteId - Site ID for timezone lookup 122 * @returns {Promise<{ blocked: boolean, reason?: string }>} 123 */ 124 export async function shouldBlockSMS(phone, siteId) { 125 // Skip business hours check for E2E testing 126 const isE2ETest = process.env.DATABASE_PATH && process.env.DATABASE_PATH.includes('test-e2e'); 127 if (isE2ETest) { 128 logger.info('E2E test mode: skipping business hours check'); 129 } 130 131 // Check opt-out list first 132 if (await isOptedOut(phone, null, 'sms')) { 133 return { blocked: true, reason: 'opted_out' }; 134 } 135 136 // Check business hours (skip for E2E tests) 137 if (!isE2ETest) { 138 const timezone = await getRecipientTimezone(siteId); 139 if (!isBusinessHours(timezone)) { 140 return { blocked: true, reason: 'outside_business_hours' }; 141 } 142 } 143 144 return { blocked: false }; 145 } 146 147 /** 148 * Check if email should be blocked (CAN-SPAM compliance) 149 * @param {string} email - Email address 150 * @returns {Promise<{ blocked: boolean, reason?: string }>} 151 */ 152 export async function shouldBlockEmail(email) { 153 // Check opt-out list 154 if (await isOptedOut(null, email, 'email')) { 155 return { blocked: true, reason: 'opted_out' }; 156 } 157 158 // Check unsubscribed_emails table (existing table for email unsubscribes) 159 const unsubscribed = await getOne( 160 'SELECT 1 FROM unsubscribed_emails WHERE lower(email) = lower($1) LIMIT 1', 161 [email] 162 ); 163 164 if (unsubscribed) { 165 logger.info(`Blocked: ${email} is unsubscribed`); 166 return { blocked: true, reason: 'unsubscribed' }; 167 } 168 169 return { blocked: false }; 170 } 171 172 /** 173 * Remove phone/email from opt-out list (re-subscribe) 174 * @param {string|null} phone - Phone number (E.164 format) 175 * @param {string|null} email - Email address 176 * @param {string} method - 'sms' or 'email' 177 * @returns {Promise<boolean>} - True if removed successfully 178 */ 179 export async function removeOptOut(phone, email, method) { 180 if (!phone && !email) { 181 throw new Error('Must provide phone or email for opt-out removal'); 182 } 183 184 const result = await run( 185 `DELETE FROM opt_outs 186 WHERE method = $1 187 AND (phone = $2 OR email = $3)`, 188 [method, phone, email] 189 ); 190 191 const removed = result.changes > 0; 192 193 if (removed) { 194 logger.info(`Removed opt-out: ${phone || email} for ${method}`); 195 } 196 197 return removed; 198 } 199 200 /** 201 * Process STOP keyword for SMS opt-out 202 * Handles common opt-out keywords: STOP, STOPALL, UNSUBSCRIBE, CANCEL, END, QUIT 203 * @param {string} messageBody - Inbound SMS message 204 * @param {string} phone - Phone number sending the message 205 * @returns {Promise<{ isOptOutRequest: boolean, optedOut: boolean }>} 206 */ 207 export async function processStopKeyword(messageBody, phone) { 208 const normalizedBody = messageBody.trim().toUpperCase(); 209 210 // TCPA-compliant opt-out keywords 211 const stopKeywords = ['STOP', 'STOPALL', 'UNSUBSCRIBE', 'CANCEL', 'END', 'QUIT']; 212 213 const isOptOutRequest = stopKeywords.some(keyword => normalizedBody === keyword); 214 215 if (isOptOutRequest) { 216 logger.info(`STOP keyword detected from ${phone}: "${messageBody}"`); 217 await addOptOut(phone, null, 'sms'); 218 // Propagate to cross-project suppression list 219 try { 220 await addSuppression({ phone, source: '333method', reason: 'stop_keyword' }); 221 } catch (e) { 222 logger.warn(`Suppression sync failed (non-fatal): ${e.message}`); 223 } 224 return { isOptOutRequest: true, optedOut: true }; 225 } 226 227 return { isOptOutRequest: false, optedOut: false }; 228 } 229 230 /** 231 * Process START keyword for SMS re-subscription 232 * Handles re-subscription keywords: START, UNSTOP 233 * @param {string} messageBody - Inbound SMS message 234 * @param {string} phone - Phone number sending the message 235 * @returns {Promise<{ isResubscribeRequest: boolean, resubscribed: boolean }>} 236 */ 237 export async function processStartKeyword(messageBody, phone) { 238 const normalizedBody = messageBody.trim().toUpperCase(); 239 240 // TCPA-compliant re-subscription keywords 241 const startKeywords = ['START', 'UNSTOP']; 242 243 const isResubscribeRequest = startKeywords.some(keyword => normalizedBody === keyword); 244 245 if (isResubscribeRequest) { 246 logger.info(`START keyword detected from ${phone}: "${messageBody}"`); 247 const removed = await removeOptOut(phone, null, 'sms'); 248 return { isResubscribeRequest: true, resubscribed: removed }; 249 } 250 251 return { isResubscribeRequest: false, resubscribed: false }; 252 } 253 254 export default { 255 isBusinessHours, 256 getRecipientTimezone, 257 addOptOut, 258 removeOptOut, 259 isOptedOut, 260 shouldBlockSMS, 261 shouldBlockEmail, 262 processStopKeyword, 263 processStartKeyword, 264 };