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