From 3f9ecfd74c70e652b4da02b2bf54762f1fb4e233 Mon Sep 17 00:00:00 2001 From: Josh Date: Tue, 17 Jun 2025 17:39:16 +0000 Subject: [PATCH] Added SMS text reminders from Twilio --- backend/config/mysqlPool.js | 29 +++++++++++++ backend/jobs/reminderCron.js | 48 ++++++++++++++++++++++ backend/server.js | 18 +++++--- backend/server3.js | 61 ++++++++++++++++++++++------ backend/utils/smsService.js | 79 ++++++++++++++++++++++++++++++++++++ ecosystem.config.cjs | 70 ++++++++++++++++++++++++++------ package-lock.json | 22 ++++++++-- package.json | 1 + public/sms-consent.html | 15 +++++++ src/components/SignUp.js | 30 ++++++++++++-- 10 files changed, 336 insertions(+), 37 deletions(-) create mode 100644 backend/config/mysqlPool.js create mode 100644 backend/jobs/reminderCron.js create mode 100644 backend/utils/smsService.js create mode 100644 public/sms-consent.html diff --git a/backend/config/mysqlPool.js b/backend/config/mysqlPool.js new file mode 100644 index 0000000..2c6fafb --- /dev/null +++ b/backend/config/mysqlPool.js @@ -0,0 +1,29 @@ +// backend/config/mysqlPool.js +import mysql from 'mysql2/promise'; +import dotenv from 'dotenv'; +import { fileURLToPath } from 'url'; +import path from 'path'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +// load .env. +dotenv.config({ path: path.resolve(__dirname, '..', `.env.${process.env.NODE_ENV || 'development'}`) }); + +/** decide: socket vs TCP */ +let poolConfig; +if (process.env.DB_SOCKET) { + poolConfig = { socketPath: process.env.DB_SOCKET }; +} else { + poolConfig = { + host : process.env.DB_HOST || '127.0.0.1', + port : process.env.DB_PORT || 3306, + user : process.env.DB_USER || 'root', + password: process.env.DB_PASSWORD || '', + }; +} +poolConfig.database = process.env.DB_NAME || 'user_profile_db'; +poolConfig.waitForConnections = true; +poolConfig.connectionLimit = 10; + +export default mysql.createPool(poolConfig); diff --git a/backend/jobs/reminderCron.js b/backend/jobs/reminderCron.js new file mode 100644 index 0000000..1516718 --- /dev/null +++ b/backend/jobs/reminderCron.js @@ -0,0 +1,48 @@ +// backend/jobs/reminderCron.js +import cron from 'node-cron'; +import pool from '../config/mysqlPool.js'; +import { sendSMS } from '../utils/smsService.js'; + +const BATCH_SIZE = 25; // tune as you like + +/* Every minute */ +cron.schedule('*/1 * * * *', async () => { + try { + /* 1️⃣ Fetch at most BATCH_SIZE reminders that are due */ + const [rows] = await pool.query( + `SELECT id, + phone_e164 AS toNumber, + message_body AS body + FROM reminders + WHERE status = 'pending' + AND send_at_utc <= UTC_TIMESTAMP() + ORDER BY send_at_utc ASC + LIMIT ?`, + [BATCH_SIZE] + ); + + if (!rows.length) return; // nothing to do + + let sent = 0, failed = 0; + + /* 2️⃣ Fire off each SMS (sendSMS handles its own DB status update) */ + for (const r of rows) { + try { + await sendSMS({ // ← updated signature + reminderId: r.id, + to : r.toNumber, + body : r.body + }); + sent++; + } catch (err) { + console.error('[reminderCron] Twilio error:', err?.message || err); + failed++; + /* sendSMS already logged the failure + updated status */ + } + } + + console.log(`[reminderCron] processed ${rows.length}: ${sent} sent, ${failed} failed`); + } catch (err) { + console.error('[reminderCron] DB error:', err); + } +}); diff --git a/backend/server.js b/backend/server.js index 2a46a49..d3c40a0 100755 --- a/backend/server.js +++ b/backend/server.js @@ -140,6 +140,8 @@ app.post('/api/register', async (req, res) => { state, area, career_situation, + phone_e164, + sms_opt_in } = req.body; if ( @@ -155,18 +157,23 @@ app.post('/api/register', async (req, res) => { return res.status(400).json({ error: 'Missing required fields.' }); } + // If they opted-in, phone must be in +E.164 format + if (sms_opt_in && !/^\+\d{8,15}$/.test(phone_e164 || '')) { + return res.status(400).json({ error: 'Phone must be +E.164 format.' }); + } + try { const hashedPassword = await bcrypt.hash(password, 10); // 1) Insert into user_profile const profileQuery = ` INSERT INTO user_profile - (username, firstname, lastname, email, zipcode, state, area, career_situation) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) + (username, firstname, lastname, email, zipcode, state, area, career_situation, phone_e164, sms_opt_in) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `; pool.query( profileQuery, - [username, firstname, lastname, email, zipcode, state, area, career_situation], + [username, firstname, lastname, email, zipcode, state, area, career_situation,phone_e164 || null, sms_opt_in ? 1 : 0], (errProfile, resultProfile) => { if (errProfile) { console.error('Error inserting user_profile:', errProfile.message); @@ -211,8 +218,9 @@ app.post('/api/register', async (req, res) => { state, area, career_situation, - // any other fields you want - }; + phone_e164, + sms_opt_in: !!sms_opt_in, + }; return res.status(201).json({ message: 'User registered successfully', diff --git a/backend/server3.js b/backend/server3.js index 1ecc3e5..fa7c48a 100644 --- a/backend/server3.js +++ b/backend/server3.js @@ -14,9 +14,13 @@ import { fileURLToPath } from 'url'; import jwt from 'jsonwebtoken'; import { v4 as uuidv4 } from 'uuid'; import pkg from 'pdfjs-dist'; -import mysql from 'mysql2/promise'; // <-- MySQL instead of SQLite +import db from './config/mysqlPool.js'; // Adjust path as necessary +import './jobs/reminderCron.js'; import OpenAI from 'openai'; import Fuse from 'fuse.js'; +import { createReminder } from './utils/smsService.js'; + +const pool = db; // Basic file init const __filename = fileURLToPath(import.meta.url); @@ -45,17 +49,6 @@ function internalFetch(req, url, opts = {}) { } -// 1) Create a MySQL pool using your environment variables -const pool = mysql.createPool({ - host: process.env.DB_HOST || 'localhost', - user: process.env.DB_USER || 'root', - password: process.env.DB_PASSWORD || '', - database: process.env.DB_NAME || 'user_profile_db', - waitForConnections: true, - connectionLimit: 10, - queueLimit: 0 -}); - // 2) Basic middlewares app.use(helmet()); app.use(express.json({ limit: '5mb' })); @@ -2409,6 +2402,24 @@ app.post('/api/premium/tasks', authenticatePremiumUser, async (req, res) => { due_date: due_date || null, status: 'not_started' }; + + /* ───────────────── SMS reminder ───────────────── */ + if (due_date) { // only if task has a due date + const [[profile]] = await pool.query( + 'SELECT phone_e164, sms_opt_in FROM user_profile WHERE id = ?', + [req.id] + ); + if (profile?.sms_opt_in && profile.phone_e164) { + await createReminder({ + userId : req.id, + phone : profile.phone_e164, + body : `🔔 AptivaAI: “${title}” is due ${due_date.slice(0,10)}`, + sendAtUtc: new Date(due_date).toISOString() // UTC ISO + }); + console.log('[reminder] queued for task', title); + } + } + res.status(201).json(newTask); } catch (err) { console.error('Error creating task:', err); @@ -3165,6 +3176,32 @@ app.get('/api/premium/resume/remaining', authenticatePremiumUser, async (req, re } }); + +app.post('/api/premium/reminders', authenticatePremiumUser, async (req, res) => { + const { phoneE164, messageBody, sendAtUtc } = req.body; + + if (!phoneE164 || !messageBody || !sendAtUtc) { + return res.status(400).json({ + error: 'phoneE164, messageBody, and sendAtUtc are required.' + }); + } + + try { + // helper writes the row; cron will pick it up + const id = await createReminder({ + userId: req.id, + phone: phoneE164, + body: messageBody.slice(0, 320), // SMS segment limit + sendAtUtc + }); + + return res.json({ id }); + } catch (err) { + console.error('Reminder create failed:', err); + return res.status(500).json({ error: 'Failed to schedule reminder.' }); + } +}); + /* ------------------------------------------------------------------ FALLBACK 404 ------------------------------------------------------------------ */ diff --git a/backend/utils/smsService.js b/backend/utils/smsService.js new file mode 100644 index 0000000..70e293e --- /dev/null +++ b/backend/utils/smsService.js @@ -0,0 +1,79 @@ +// backend/utils/smsService.js +// Centralised Twilio helper + DB helpers for the Reminders feature. +// Now *also* writes back status → reminders.status and sent_at so the +// cron‑job doesn’t need its own UPDATE logic. + +import twilio from 'twilio'; +import { v4 as uuid } from 'uuid'; +import db from '../config/mysqlPool.js'; + +const { + TWILIO_ACCOUNT_SID, + TWILIO_AUTH_TOKEN, + TWILIO_MESSAGING_SERVICE_SID +} = process.env; + +if (!TWILIO_ACCOUNT_SID || !TWILIO_AUTH_TOKEN || !TWILIO_MESSAGING_SERVICE_SID) { + throw new Error('Twilio env vars missing; check env or PM2 config'); +} + +const client = twilio(TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN); + +/* ────────────────────────────────────────────────────────────── * + Immediate send + status update + ------------------------------------------------------------------ */ +export async function sendSMS ({ reminderId = null, to, body }) { + try { + const msg = await client.messages.create({ + to, + body, + messagingServiceSid: TWILIO_MESSAGING_SERVICE_SID + }); + + // Mark success if we were called from reminderCron + if (reminderId) { + await db.execute( + `UPDATE reminders + SET status = 'sent', + sent_at = UTC_TIMESTAMP(), + twilio_sid = ? + WHERE id = ?`, + [msg.sid, reminderId] + ); + } + + return msg; + } catch (err) { + // Persist failure so we don’t keep retrying blindly + if (reminderId) { + await db.execute( + `UPDATE reminders + SET status = 'failed', + sent_at = UTC_TIMESTAMP(), + error_code = ? + WHERE id = ?`, + [err.code || null, reminderId] + ); + } + throw err; // propagate so cron can log + } +} + +/* ────────────────────────────────────────────────────────────── * + Persist a *future* reminder row + ------------------------------------------------------------------ */ +export async function createReminder ({ userId, phone, body, sendAtUtc }) { + const id = uuid(); + const mysqlDateTime = new Date(sendAtUtc) + .toISOString() + .slice(0, 19) // 2025-06-17T22:00:00 + .replace('T', ' '); // 2025-06-17 22:00:00 + + await db.execute( + `INSERT INTO reminders (id, user_id, phone_e164, message_body, send_at_utc) + VALUES (?,?,?,?,?)`, + [id, userId, phone, body.slice(0, 320), mysqlDateTime] + ); + + return id; +} diff --git a/ecosystem.config.cjs b/ecosystem.config.cjs index 331b323..b0fdcd7 100644 --- a/ecosystem.config.cjs +++ b/ecosystem.config.cjs @@ -1,22 +1,66 @@ module.exports = { - apps : [{ + apps: [ + /* ─────────────── SERVER-2 ─────────────── */ + { name: 'server2', script: './backend/server2.js', - env_production: { - NODE_ENV: 'production', - ONET_USERNAME: 'aptivaai', - ONET_PASSWORD: '2296ahq', - }, + watch: false, // or true in dev + env_development: { NODE_ENV: 'development', ONET_USERNAME: 'aptivaai', - ONET_PASSWORD: '2296ahq', - },script: 'index.js', - watch: '.' - }, { - script: './service-worker/', - watch: ['./service-worker'] - }], + ONET_PASSWORD: '2296ahq' + }, + env_production: { + NODE_ENV: 'production', + ONET_USERNAME: 'aptivaai', + ONET_PASSWORD: '2296ahq' + } + }, + + /* ─────────────── SERVER-3 (Premium) ─────────────── */ + { + name: 'server3', + script: './backend/server3.js', + watch: false, // set true if you want auto-reload in dev + + env_development: { + NODE_ENV : 'development', + PREMIUM_PORT : 5002, + + /* Twilio */ + TWILIO_ACCOUNT_SID : 'ACd700c6fb9f691ccd9ccab73f2dd4173d', + TWILIO_AUTH_TOKEN : 'fb8979ccb172032a249014c9c30eba80', + TWILIO_MESSAGING_SERVICE_SID : 'MGaa07992a9231c841b1bfb879649026d6', + + /* DB */ + DB_HOST : '34.67.180.54', + DB_PORT : 3306, + DB_USER : 'sqluser', + DB_PASSWORD : 'psSMS Consent & Opt-In Terms + +

+When you check the box “Send me SMS task-reminder texts (standard rates apply)” during Premium +onboarding, you agree to receive recurring, automated text messages from AptivaAI at the phone number +you provided. Message frequency depends on your task schedule. Message and data rates may apply. +

+ +

+Reply STOP at any time to cancel, or HELP for help. You can also toggle SMS +reminders off inside your account settings. For more details, see our +Privacy Policy and Terms of Service. +

+ +

Questions? Email support@aptiva.com.

diff --git a/src/components/SignUp.js b/src/components/SignUp.js index 3f02109..db73e9c 100644 --- a/src/components/SignUp.js +++ b/src/components/SignUp.js @@ -4,6 +4,7 @@ import { Button } from './ui/button.js'; import SituationCard from './ui/SituationCard.js'; import PromptModal from './ui/PromptModal.js'; + const careerSituations = [ { id: "planning", @@ -55,8 +56,9 @@ function SignUp() { const [areas, setAreas] = useState([]); const [error, setError] = useState(''); const [loadingAreas, setLoadingAreas] = useState(false); + const [phone, setPhone] = useState(''); + const [optIn, setOptIn] = useState(false); - // new states const [showCareerSituations, setShowCareerSituations] = useState(false); const [selectedSituation, setSelectedSituation] = useState(null); const [showPrompt, setShowPrompt] = useState(false); @@ -106,7 +108,7 @@ function SignUp() { }, [state]); const validateFields = async () => { - const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/; + const emailRegex = /^[^\s@]@[^\s@]\.[^\s@]{2,}$/; const zipRegex = /^\d{5}$/; const passwordRegex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[!@#$%^&*]).{8,}$/; @@ -191,6 +193,8 @@ const handleSituationConfirm = async () => { zipcode, state, area, + phone_e164 : phone, + sms_opt_in : optIn, career_situation: selectedSituation.id }), }); @@ -216,7 +220,7 @@ const handleSituationConfirm = async () => { // But if your App.js auto-fetches the user from the token, you can skip this } - // Now that we have a token + user, let's direct them to the route + // Now that we have a token user, let's direct them to the route navigate(selectedSituation.route); } catch (err) { console.error('Registration error:', err); @@ -281,6 +285,26 @@ return ( value={confirmEmail} onChange={(e) => setConfirmEmail(e.target.value)} /> + + {/* ─────────────── New: Mobile number ─────────────── */} + setPhone(e.target.value)} + /> + + {/* New: SMS opt-in checkbox */} + +