Added SMS text reminders from Twilio

This commit is contained in:
Josh 2025-06-17 17:39:16 +00:00
parent 4756d8614a
commit fc7a60e946
10 changed files with 336 additions and 37 deletions

View File

@ -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.<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);

View File

@ -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);
}
});

View File

@ -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',

View File

@ -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
------------------------------------------------------------------ */

View File

@ -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
// cronjob doesnt 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 dont 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;
}

View File

@ -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 : 'ps<g+2DO-eTb2mb5',
DB_NAME : 'user_profile_db'
},
env_production: {
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 : 'ps<g+2DO-eTb2mb5',
DB_NAME : 'user_profile_db'
},
},
],
deploy : {
production : {

22
package-lock.json generated
View File

@ -53,6 +53,7 @@
"tailwind-merge": "^3.2.0",
"tailwindcss-animate": "^1.0.7",
"twilio": "^5.7.1",
"uuid": "^11.1.0",
"web-vitals": "^4.2.4",
"xlsx": "^0.18.5"
},
@ -17654,6 +17655,15 @@
"websocket-driver": "^0.7.4"
}
},
"node_modules/sockjs/node_modules/uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/socks": {
"version": "2.8.4",
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.4.tgz",
@ -19532,12 +19542,16 @@
}
},
"node_modules/uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"version": "11.1.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
"integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
"uuid": "dist/esm/bin/uuid"
}
},
"node_modules/v8-to-istanbul": {

View File

@ -48,6 +48,7 @@
"tailwind-merge": "^3.2.0",
"tailwindcss-animate": "^1.0.7",
"twilio": "^5.7.1",
"uuid": "^11.1.0",
"web-vitals": "^4.2.4",
"xlsx": "^0.18.5"
},

15
public/sms-consent.html Normal file
View File

@ -0,0 +1,15 @@
<h1>SMS Consent & Opt-In Terms</h1>
<p>
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.
</p>
<p>
Reply <strong>STOP</strong> at any time to cancel, or <strong>HELP</strong> for help. You can also toggle SMS
reminders off inside your account settings. For more details, see our
<a href="/privacy">Privacy Policy</a> and <a href="/terms">Terms of Service</a>.
</p>
<p>Questions? Email support@aptiva.com.</p>

View File

@ -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 ─────────────── */}
<react-phone-input
country="us"
className="w-full px-3 py-2 border rounded-md"
placeholder="Mobile (15555555555)"
value={phone}
onChange={(e) => setPhone(e.target.value)}
/>
{/* New: SMS opt-in checkbox */}
<label className="inline-flex items-center gap-2">
<input
type="checkbox"
checked={optIn}
onChange={e => setOptIn(e.target.checked)}
/>
I agree to receive SMS reminders
</label>
<input
className="w-full px-3 py-2 border rounded-md"
placeholder="Zip Code"