← back to B Version 1

sms-scheduler/sms-scheduler.ts

323 lines

/**
 * Dear Bubbe SMS Scheduler
 *
 * Sends daily Bubbe wisdom via SMS using Twilio
 */

import { Twilio } from 'twilio';
import cron from 'node-cron';
import { Pool } from 'pg';

// Twilio configuration
const twilioClient = new Twilio(
  process.env.TWILIO_ACCOUNT_SID || '',
  process.env.TWILIO_AUTH_TOKEN || ''
);

const TWILIO_PHONE_NUMBER = process.env.TWILIO_PHONE_NUMBER || '';

// Database configuration
const pool = new Pool({
  connectionString: process.env.DATABASE_URL || 'postgresql://localhost/dear_bubbe'
});

interface Subscriber {
  id: string;
  phoneNumber: string;
  timezone: string;
  deliveryTime: string;
  active: boolean;
  categoriesPreference: string[];
  frequency: string;
}

interface BubbeMessage {
  id: string;
  bubbeRewrittenQuestion: string;
  bubbeAnswer: string;
  category: string;
  sentCount: number;
}

/**
 * Get active subscribers ready for today's message
 */
async function getActiveSubscribers(): Promise<Subscriber[]> {
  const query = `
    SELECT
      id,
      phone_number as "phoneNumber",
      timezone,
      delivery_time as "deliveryTime",
      active,
      categories_preference as "categoriesPreference",
      frequency
    FROM subscribers
    WHERE active = TRUE
  `;

  const result = await pool.query(query);
  return result.rows;
}

/**
 * Select a Bubbe message for a subscriber
 * Ensures variety and avoids recent repeats
 */
async function selectMessageForSubscriber(subscriber: Subscriber): Promise<BubbeMessage | null> {
  const categoryFilter = subscriber.categoriesPreference && subscriber.categoriesPreference.length > 0
    ? `AND category = ANY($1::text[])`
    : '';

  const query = `
    SELECT
      id,
      bubbe_rewritten_question as "bubbeRewrittenQuestion",
      bubbe_answer as "bubbeAnswer",
      category,
      sent_count as "sentCount"
    FROM dear_bubbe_entries
    WHERE approved = TRUE
      AND sms_length_ok = TRUE
      AND (next_eligible_date IS NULL OR next_eligible_date <= CURRENT_DATE)
      ${categoryFilter}
      AND id NOT IN (
        SELECT entry_id
        FROM sms_delivery_log
        WHERE phone_number = $${categoryFilter ? '2' : '1'}
          AND sent_at > CURRENT_DATE - INTERVAL '30 days'
      )
    ORDER BY sent_count ASC, RANDOM()
    LIMIT 1
  `;

  const params = categoryFilter
    ? [subscriber.categoriesPreference, subscriber.phoneNumber]
    : [subscriber.phoneNumber];

  const result = await pool.query(query, params);

  return result.rows.length > 0 ? result.rows[0] : null;
}

/**
 * Send SMS via Twilio
 */
async function sendSMS(phoneNumber: string, message: string): Promise<{
  success: boolean;
  sid?: string;
  error?: string;
}> {
  try {
    const result = await twilioClient.messages.create({
      body: message,
      from: TWILIO_PHONE_NUMBER,
      to: phoneNumber
    });

    return {
      success: true,
      sid: result.sid
    };
  } catch (error: any) {
    return {
      success: false,
      error: error.message
    };
  }
}

/**
 * Log SMS delivery
 */
async function logDelivery(
  entryId: string,
  phoneNumber: string,
  status: string,
  twilioSid?: string,
  errorMessage?: string
) {
  await pool.query(`
    INSERT INTO sms_delivery_log (
      entry_id,
      phone_number,
      delivery_status,
      twilio_sid,
      error_message
    ) VALUES ($1, $2, $3, $4, $5)
  `, [entryId, phoneNumber, status, twilioSid, errorMessage]);
}

/**
 * Update entry stats after sending
 */
async function updateEntrySta(entryId: string) {
  await pool.query(`
    UPDATE dear_bubbe_entries
    SET
      sent_count = sent_count + 1,
      last_sent_date = CURRENT_TIMESTAMP,
      next_eligible_date = CURRENT_DATE + INTERVAL '30 days'
    WHERE id = $1
  `, [entryId]);
}

/**
 * Update subscriber stats
 */
async function updateSubscriberStats(subscriberId: string) {
  await pool.query(`
    UPDATE subscribers
    SET total_messages_received = total_messages_received + 1
    WHERE id = $1
  `, [subscriberId]);
}

/**
 * Process daily SMS sends
 */
async function processDailySends() {
  console.log('\n📱 Starting daily Bubbe SMS delivery...\n');

  const subscribers = await getActiveSubscribers();
  console.log(`Found ${subscribers.length} active subscribers`);

  let successCount = 0;
  let failCount = 0;

  for (const subscriber of subscribers) {
    try {
      // Select message
      const message = await selectMessageForSubscriber(subscriber);

      if (!message) {
        console.log(`⚠️  No eligible message for ${subscriber.phoneNumber}`);
        continue;
      }

      // Format SMS text
      const smsText = `Dear Bubbe:\n\n${message.bubbeRewrittenQuestion}\n\n${message.bubbeAnswer}\n\n💙 Reply STOP to unsubscribe`;

      // Send SMS
      const result = await sendSMS(subscriber.phoneNumber, smsText);

      if (result.success) {
        // Log success
        await logDelivery(message.id, subscriber.phoneNumber, 'sent', result.sid);
        await updateEntrySta(message.id);
        await updateSubscriberStats(subscriber.id);

        successCount++;
        console.log(`✅ Sent to ${subscriber.phoneNumber}: ${message.category}`);
      } else {
        // Log failure
        await logDelivery(message.id, subscriber.phoneNumber, 'failed', undefined, result.error);
        failCount++;
        console.log(`❌ Failed to send to ${subscriber.phoneNumber}: ${result.error}`);
      }

      // Rate limiting - Twilio allows 10/sec but let's be gentle
      await new Promise(resolve => setTimeout(resolve, 500));

    } catch (error: any) {
      console.error(`Error processing ${subscriber.phoneNumber}:`, error.message);
      failCount++;
    }
  }

  console.log(`\n📊 Delivery Summary:`);
  console.log(`   ✅ Successful: ${successCount}`);
  console.log(`   ❌ Failed: ${failCount}`);
  console.log(`   📧 Total: ${subscribers.length}\n`);

  return { successCount, failCount };
}

/**
 * Handle STOP requests (Twilio webhook)
 */
async function handleStopRequest(phoneNumber: string) {
  await pool.query(`
    UPDATE subscribers
    SET active = FALSE, unsubscribed_at = CURRENT_TIMESTAMP
    WHERE phone_number = $1
  `, [phoneNumber]);

  console.log(`🛑 Unsubscribed: ${phoneNumber}`);
}

/**
 * Handle START requests (Twilio webhook)
 */
async function handleStartRequest(phoneNumber: string) {
  // Check if subscriber exists
  const existing = await pool.query(`
    SELECT id FROM subscribers WHERE phone_number = $1
  `, [phoneNumber]);

  if (existing.rows.length > 0) {
    // Reactivate
    await pool.query(`
      UPDATE subscribers
      SET active = TRUE, unsubscribed_at = NULL
      WHERE phone_number = $1
    `, [phoneNumber]);

    console.log(`✅ Reactivated: ${phoneNumber}`);
  } else {
    // New subscriber
    await pool.query(`
      INSERT INTO subscribers (phone_number, active)
      VALUES ($1, TRUE)
    `, [phoneNumber]);

    console.log(`🆕 New subscriber: ${phoneNumber}`);
  }

  // Send welcome message
  await sendSMS(phoneNumber, "Welcome to Dear Bubbe! You'll receive daily Yiddish wisdom at 8am. Reply STOP anytime to unsubscribe. Oy vey, you're going to love this!");
}

/**
 * Setup cron jobs
 */
function setupCronJobs() {
  // Daily at 8:00 AM (adjust timezone as needed)
  cron.schedule('0 8 * * *', async () => {
    console.log('\n⏰ Cron triggered: Daily Bubbe delivery');
    await processDailySends();
  });

  console.log('✅ Cron job scheduled: Daily at 8:00 AM');
}

// Export functions
export {
  processDailySends,
  handleStopRequest,
  handleStartRequest,
  setupCronJobs
};

// CLI usage
if (require.main === module) {
  const command = process.argv[2];

  if (command === 'send-now') {
    processDailySends()
      .then(() => process.exit(0))
      .catch(err => {
        console.error('Error:', err);
        process.exit(1);
      });
  } else if (command === 'start-scheduler') {
    console.log('🥯 Dear Bubbe SMS Scheduler Starting...\n');
    setupCronJobs();
    console.log('\n✨ Scheduler running. Press Ctrl+C to stop.\n');
  } else {
    console.log('Usage:');
    console.log('  send-now         Send SMS to all subscribers immediately');
    console.log('  start-scheduler  Start the cron scheduler');
  }
}