Added SMS text reminders from Twilio
This commit is contained in:
parent
631e2bc0fb
commit
3f9ecfd74c
29
backend/config/mysqlPool.js
Normal file
29
backend/config/mysqlPool.js
Normal 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);
|
48
backend/jobs/reminderCron.js
Normal file
48
backend/jobs/reminderCron.js
Normal 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);
|
||||
}
|
||||
});
|
@ -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',
|
||||
|
@ -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
|
||||
------------------------------------------------------------------ */
|
||||
|
79
backend/utils/smsService.js
Normal file
79
backend/utils/smsService.js
Normal 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
|
||||
// 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;
|
||||
}
|
@ -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
22
package-lock.json
generated
@ -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": {
|
||||
|
@ -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
15
public/sms-consent.html
Normal 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>
|
@ -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"
|
||||
|
Loading…
Reference in New Issue
Block a user