Text reminders A2P
Some checks failed
ci/woodpecker/manual/woodpecker Pipeline failed

This commit is contained in:
Josh 2025-09-15 15:23:17 +00:00
parent 6e673ed514
commit 219493e1b0
14 changed files with 146 additions and 42 deletions

View File

@ -1 +1 @@
33fb91d4b60b7f14d236f83e44c9db42fa1d440f-372bcf506971f56c4911b429b9f5de5bc37ed008-e9eccd451b778829eb2f2c9752c670b707e1268b
767a2e51259e707655c80d6449afa93abf982fec-372bcf506971f56c4911b429b9f5de5bc37ed008-e9eccd451b778829eb2f2c9752c670b707e1268b

View File

@ -1024,7 +1024,8 @@ app.post('/api/user-profile', requireAuth, async (req, res) => {
career_priorities,
career_list,
phone_e164,
sms_opt_in
sms_opt_in,
sms_reminders_opt_in
} = req.body;
try {
@ -1069,6 +1070,10 @@ app.post('/api/user-profile', requireAuth, async (req, res) => {
? (sms_opt_in ? 1 : 0)
: (existing?.sms_opt_in ?? 0);
const smsRemindersFinal = (typeof sms_reminders_opt_in === 'boolean')
? (sms_reminders_opt_in ? 1 : 0)
: (existing?.sms_reminders_opt_in ?? 0);
if (existing) {
const updateQuery = `
UPDATE user_profile
@ -1086,7 +1091,14 @@ app.post('/api/user-profile', requireAuth, async (req, res) => {
career_priorities = ?,
career_list = ?,
phone_e164 = ?,
sms_opt_in = ?
sms_opt_in = ?,
sms_reminders_opt_in = ?
sms_reminders_opt_in_at =
CASE
WHEN ? = 1 AND (sms_reminders_opt_in IS NULL OR sms_reminders_opt_in = 0)
THEN UTC_TIMESTAMP()
ELSE sms_reminders_opt_in_at
END
WHERE id = ?
`;
const params = [
@ -1105,6 +1117,7 @@ app.post('/api/user-profile', requireAuth, async (req, res) => {
finalCareerList,
phoneFinal,
smsOptFinal,
smsRemindersFinal,
profileId
];
@ -1116,17 +1129,17 @@ app.post('/api/user-profile', requireAuth, async (req, res) => {
INSERT INTO user_profile
(id, username, firstname, lastname, email, email_lookup, zipcode, state, area,
career_situation, interest_inventory_answers, riasec_scores,
career_priorities, career_list, phone_e164, sms_opt_in)
career_priorities, career_list, phone_e164, sms_opt_in, sms_reminders_opt_in)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?,
?, ?, ?,
?, ?, ?, ?)
?, ?, ?, ?, ?)
`;
const params = [
profileId,
finalUserName,
firstName,
lastName,
encEmail, // <-- was emailNorm
encEmail,
emailLookupVal,
zipCode,
state,
@ -1137,7 +1150,8 @@ app.post('/api/user-profile', requireAuth, async (req, res) => {
finalCareerPriorities,
finalCareerList,
phoneFinal,
smsOptFinal
smsOptFinal,
smsRemindersFinal
];

View File

@ -19,7 +19,7 @@ import pkg from 'pdfjs-dist';
import pool from './config/mysqlPool.js';
import { v4 as uuid } from 'uuid';
import { decrypt } from './shared/crypto/encryption.js'
import crypto from 'crypto';
import OpenAI from 'openai';
import Fuse from 'fuse.js';
import Stripe from 'stripe';
@ -690,7 +690,7 @@ const twilioForm = express.urlencoded({ extended: false });
app.post('/api/auth/sms/inbound', twilioForm, async (req, res) => {
const body = String(req.body?.Body || '').trim().toUpperCase();
if (body === 'HELP') {
const twiml = `<?xml version="1.0" encoding="UTF-8"?><Response><Message>AptivaAI: Help with SMS. Email admin@aptivaai.com. Msg&Data rates may apply. Reply STOP to cancel.</Message></Response>`;
const twiml = `<?xml version="1.0" encoding="UTF-8"?><Response><Message>AptivaAI: Help with SMS. Email support@aptivaai.com. Msg&amp;Data rates may apply. Reply STOP to cancel.</Message></Response>`;
return res.type('text/xml').send(twiml);
}
return res.type('text/xml').send(`<?xml version="1.0" encoding="UTF-8"?><Response/>`);
@ -4024,11 +4024,11 @@ app.post('/api/premium/tasks', authenticatePremiumUser, async (req, res) => {
/* ───────────────── 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) {
const [[profile]] = await pool.query(
'SELECT phone_e164, phone_verified_at, sms_reminders_opt_in FROM user_profile WHERE id = ?',
[req.id]
);
if (profile?.sms_reminders_opt_in && profile.phone_verified_at && profile.phone_e164) {
await createReminder({
userId : req.id,
phone : profile.phone_e164,

View File

@ -252,3 +252,8 @@ mysqldump \
ALTER TABLE user_profile
ADD COLUMN email_verified_at DATETIME NULL AFTER email_lookup,
ADD COLUMN phone_verified_at DATETIME NULL AFTER phone_e164;
ALTER TABLE user_profile
ADD COLUMN sms_reminders_opt_in TINYINT(1) NOT NULL DEFAULT 0,
ADD COLUMN sms_reminders_opt_in_at DATETIME NULL;

View File

@ -30,6 +30,13 @@ http {
listen [::]:80;
server_name dev1.aptivaai.com;
return 301 https://$host$request_uri;
location ^~ /api/auth/sms/ {
proxy_pass http://backend5002; # server3
proxy_http_version 1.1;
proxy_set_header Connection "";
}
}
########################################################################
@ -171,6 +178,13 @@ http {
location ^~ /api/auth/ { limit_conn perip 5;
limit_req zone=reqperip burst=10 nodelay;
proxy_pass http://backend5000; }
location ^~ /api/auth/sms/ {
proxy_pass http://backend5002; # server3
proxy_http_version 1.1;
proxy_set_header Connection "";
}
location = /api/user-profile { limit_conn perip 5;
limit_req zone=reqperip burst=10 nodelay;
proxy_pass http://backend5000; }
@ -208,6 +222,13 @@ http {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
}
# Twilio webhooks live in server3 (backend5002)
location ^~ /api/auth/sms/ {
proxy_pass http://backend5002;
proxy_http_version 1.1;
proxy_set_header Connection "";
}
}
########################################################################

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

View File

@ -4,7 +4,7 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<meta name="robots" content="noindex" />
<title>AptivaAI A2P 10DLC Campaign Verification</title>
<title>AptivaAI - A2P 10DLC Campaign Verification</title>
<style>
:root { --fg:#111; --muted:#555; --link:#1d4ed8; }
body { font:16px/1.6 system-ui,Segoe UI,Roboto,Arial,sans-serif; color:var(--fg);
@ -23,7 +23,7 @@
</style>
</head>
<body>
<h1>AptivaAI A2P 10DLC Campaign Verification</h1>
<h1>AptivaAI - A2P 10DLC Campaign Verification</h1>
<div class="muted">Last updated: Sep 13, 2025</div>
<h2>Program &amp; Use Case</h2>
@ -38,6 +38,8 @@
</ul>
</div>
<p><strong>Originating number(s):</strong> +1 478-500-3955</p>
<h2>Call to Action (CTA) & Consent</h2>
<p>Users opt in <em>inside their account</em> by entering a mobile number, checking a non-prechecked consent box linking to our
<a href="/sms" target="_blank" rel="noreferrer">SMS Terms</a>, <a href="/legal/privacy" target="_blank" rel="noreferrer">Privacy Policy</a>, and
@ -66,8 +68,10 @@
<p>All samples include brand and opt-out/help language; no shortened links or phone numbers in 2FA content.</p>
<ul>
<li><code>AptivaAI code: 123456. Expires in 10 minutes. Reply STOP to cancel, HELP for help.</code></li>
<li><code>AptivaAI sign-in code: 654321. If you didnt request this, change your password.</code></li>
<li><code>AptivaAI sign-in code: 654321. If you didn't request this, change your password.</code></li>
<li><code>AptivaAI password reset code: 112233. Expires in 10 minutes. Reply STOP to cancel.</code></li>
<li><code>AptivaAI: SMS for verification & security alerts enabled. Message & Data rates may apply. Reply HELP for help, STOP to cancel. Frequency varies.</code></li>
</ul>
</div>
@ -91,12 +95,46 @@
<img src="/a2p/assets/opt-in-checkbox.png" alt="Consent checkbox and copy" />
<figcaption>Consent checkbox (non-prechecked) linking to policies</figcaption>
</figure>
<figure>
<img src="/a2p/assets/otp-sms.png" alt="Example 2FA SMS on device" />
<figcaption>Example OTP SMS</figcaption>
</figure>
</div>
<p class="muted">Carriers are not liable for delayed or undelivered messages.</p>
</body>
</html>
<h2>Reminders (Account Notifications)</h2>
<div class="card">
<p><strong>Program:</strong> AptivaAI Reminders (account notifications; non-marketing)</p>
<p><strong>Originating number(s):</strong> +1 678-710-3755</p>
<ul>
<li><strong>What we send:</strong> task & milestone reminders the user enables in Settings.</li>
<li><strong>Opt-out:</strong> Reply <strong>STOP</strong> to cancel; <strong>HELP</strong> for help.</li>
<li><strong>Fees:</strong> Msg &amp; data rates may apply.</li>
<li><strong>Frequency:</strong> varies by user settings.</li>
<li><strong>Consent:</strong> not a condition of purchase or service.</li>
<li><strong>Policies:</strong>
<a href="/sms" target="_blank" rel="noreferrer">SMS Terms</a>
<a href="/legal/privacy" target="_blank" rel="noreferrer">Privacy Policy</a>
<a href="/legal/terms" target="_blank" rel="noreferrer">Terms of Service</a>
</li>
<li><strong>Contact:</strong> <a href="mailto:support@aptivaai.com">support@aptivaai.com</a></li>
</ul>
</div>
<div class="grid" style="margin-top:10px">
<figure>
<img src="/a2p/assets/reminders-toggle.png"
alt="In-app Reminders toggle with non-prechecked consent and policy links">
<figcaption>In-app CTA: non-prechecked consent for SMS reminders inside the account.</figcaption>
</figure>
</div>
<h3>Sample Messages</h3>
<div class="card">
<ul>
<li><code>AptivaAI reminder: “[Task name]” is due tomorrow. Reply STOP to cancel, HELP for help.</code></li>
<li><code>AptivaAI reminder: “[Milestone]” starts at [3:00 PM] today. Reply STOP to cancel.</code></li>
<li><code>AptivaAI reminder: Weekly goal check-in. Reply STOP to cancel, HELP for help.</code></li>
</ul>
</div>

View File

@ -10,5 +10,7 @@
</style>
<h1>Privacy Policy</h1>
<p>We collect account information (name, email), an optional phone number to enable SMS, and usage data. We use this information to provide and secure the service, including sending SMS you enable (e.g., verification codes). We share data with processors under contracts that limit use to our instructions. You can opt out of SMS by replying <strong>STOP</strong>.</p>
<p>We do not share mobile information with third parties or affiliates for marketing or promotional purposes. SMS opt-in data and consent are not shared with any third parties.</p>
<p>Contact: <a href="mailto:support@aptivaai.com">support@aptivaai.com</a></p>
<p><small>Last updated: Sep 13, 2025</small></p>

View File

@ -15,6 +15,11 @@
<li><strong>Opt-out:</strong> Reply <strong>STOP</strong> to cancel. Reply <strong>HELP</strong> for help.</li>
<li><strong>Support:</strong> <a href="mailto:support@aptivaai.com">support@aptivaai.com</a></li>
<li><strong>Carriers:</strong> Not liable for delayed or undelivered messages.</li>
<p><strong>Note:</strong> Consent is not a condition of purchase or service, and you can disable SMS in your account at any time.</p>
</ul>
<p>See our <a href="/legal/privacy">Privacy Policy</a> and <a href="/legal/terms">Terms of Service</a>.</p>
<p><small>Last updated: Sep 13, 2025</small></p>
<p><strong>Reminders Program (Account Notifications):</strong> Non-marketing reminders you enable in your account (frequency varies). Reply <strong>STOP</strong> to cancel, <strong>HELP</strong> for help. Consent is not a condition of purchase or service.</p>

View File

@ -14,8 +14,9 @@ function UserProfile() {
const [loadingAreas, setLoadingAreas] = useState(false);
const [phoneE164, setPhoneE164] = useState('');
const [smsOptIn, setSmsOptIn] = useState(false);
const [smsRemindersOptIn, setSmsRemindersOptIn] = useState(false);
const [showChangePw, setShowChangePw] = useState(false);
const [phoneVerifiedAt, setPhoneVerifiedAt] = useState(null);
// Subscription state
const [sub, setSub] = useState(null);
@ -77,12 +78,14 @@ function UserProfile() {
useEffect(() => {
(async () => {
try {
const res = await authFetch('/api/user-profile?fields=' +
[
'firstname','lastname','email',
'zipcode','state','area','career_situation',
'phone_e164','sms_opt_in'
].join(','),
const res = await authFetch(
'/api/user-profile?fields=' +
[
'firstname','lastname','email',
'zipcode','state','area','career_situation',
'phone_e164','sms_opt_in',
'phone_verified_at','sms_reminders_opt_in' // may be absent if BE not updated yet
].join(','),
{ method: 'GET' }
);
if (!res || !res.ok) return;
@ -96,7 +99,8 @@ function UserProfile() {
setSelectedArea(data.area || '');
setCareerSituation(data.career_situation || '');
setPhoneE164(data.phone_e164 || '');
setSmsOptIn(!!data.sms_opt_in);
setSmsRemindersOptIn(!!data.sms_reminders_opt_in); // falls back to false if field not returned
setPhoneVerifiedAt(data.phone_verified_at || null);
if (data.state) {
setLoadingAreas(true);
@ -150,7 +154,8 @@ function UserProfile() {
area: selectedArea,
careerSituation,
phone_e164: phoneE164 || null,
sms_opt_in: !!smsOptIn,
sms_reminders_opt_in: !!smsRemindersOptIn,
};
try {
@ -285,7 +290,7 @@ function UserProfile() {
{/* Phone + SMS */}
<div className="mt-4">
<label className="mb-1 block text-sm font-medium text-gray-700">Mobile (E.164)</label>
<label className="mb-1 block text-sm font-medium text-gray-700">Mobile</label>
<input
type="tel"
placeholder="+15551234567"
@ -293,14 +298,27 @@ function UserProfile() {
onChange={(e) => setPhoneE164(e.target.value)}
className="w-full rounded border border-gray-300 px-3 py-2 text-sm focus:border-blue-600 focus:outline-none"
/>
<label className="mt-2 inline-flex items-center gap-2 text-sm">
<label className="flex items-start gap-2 text-xs text-gray-700 leading-5 mt-2">
<input
type="checkbox"
checked={smsOptIn}
onChange={(e) => setSmsOptIn(e.target.checked)}
/>
I agree to receive SMS updates.
type="checkbox"
checked={smsRemindersOptIn}
onChange={e => setSmsRemindersOptIn(e.target.checked)}
disabled={!phoneVerifiedAt} // require a verified phone
/>
<span>
Enable SMS reminders (non-marketing). Message frequency varies. Msg &amp; data rates may apply.
Reply <strong>STOP</strong> to cancel, <strong>HELP</strong> for help.
Consent is not a condition of purchase or service. See{' '}
<a className="underline" href="/sms" target="_blank" rel="noreferrer">SMS Terms</a>,{' '}
<a className="underline" href="/legal/privacy" target="_blank" rel="noreferrer">Privacy Policy</a>, and{' '}
<a className="underline" href="/legal/terms" target="_blank" rel="noreferrer">Terms</a>.
</span>
</label>
{!phoneVerifiedAt && (
<div className="text-[11px] text-gray-500 mt-1">
Verify your number on the <a className="underline" href="/verify">Verify</a> page before enabling reminders.
</div>
)}
</div>
{/* Career Situation */}

View File

@ -126,11 +126,12 @@ export default function Verify() {
onChange={e => setSmsConsent(e.target.checked)}
/>
<label htmlFor="sms-consent" className="text-xs text-gray-700 leading-5">
By requesting a code, you agree to the{' '}
By requesting a code, you agree to receive one-time texts from AptivaAI for account verification and security alerts.
Message frequency varies. Msg &amp; data rates may apply. Reply <strong>STOP</strong> to opt out, <strong>HELP</strong> for help.
Consent is not a condition of purchase or service. See the{' '}
<a className="underline" href="/sms" target="_blank" rel="noreferrer">SMS Terms</a>,{' '}
<a className="underline" href="/legal/privacy" target="_blank" rel="noreferrer">Privacy Policy</a>,{' '}
and <a className="underline" href="/legal/terms" target="_blank" rel="noreferrer">Terms</a>.{' '}
Reply STOP to opt out. Msg &amp; data rates may apply.
<a className="underline" href="/legal/privacy" target="_blank" rel="noreferrer">Privacy Policy</a>, and{' '}
<a className="underline" href="/legal/terms" target="_blank" rel="noreferrer">Terms</a>.
</label>
</div>