Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,11 @@ RELAY_APP_ENV=
COACHATRON_EMAIL_FROM=
# Inbound texts (docs/PLATFORM.md section 3). The secret comes from the relay;
# INBOUND_SMS_URL must equal the relay manifest's inbound.sms url.
# Without the secret, production answers inbound webhooks with 503.
RELAY_INBOUND_SECRET=
INBOUND_SMS_URL=
# The URL the relay signs delivery-status callbacks with.
SMS_STATUS_WEBHOOK_PUBLIC_URL=
# The Coachatron number, shown on the landing page as "Or text it to ...".
COACHATRON_SMS_NUMBER=
PORT=3000
4 changes: 2 additions & 2 deletions docs/PLATFORM.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,8 +131,8 @@ All sends go through the relay. Rules from SPEC.md §10 stand (transactional onl
route is data in `products/manifest.json`, not an `inbound.js` change.
- **What arrives:** Twilio's own form fields (`From`, `To`, `Body`, …),
urlencoded, with `x-relay-signature` = base64 HMAC-SHA256(secret, url + raw
body). `src/lib/inboundSms.ts` checks it whenever `RELAY_INBOUND_SECRET` is
set (mint it with `node scripts/relay-keys.js inbound-secret --product
body). `src/routes/webhooks.ts` checks it whenever `RELAY_INBOUND_SECRET` is
set, and answers 503 in production when it is not (mint it with `node scripts/relay-keys.js inbound-secret --product
coachatron --rotate` in the relay repo). `INBOUND_SMS_URL` must equal the
manifest `url` exactly. The relay ignores a non-XML reply, so Coachatron
answers by sending a text, not TwiML.
Expand Down
7 changes: 7 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"dependencies": {
"@noctusoft/store-client": "file:packages/store-client",
"express": "^5.2.1",
"libphonenumber-js": "^1.13.14",
"pg": "^8.23.1"
},
"devDependencies": {
Expand Down
5 changes: 5 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ export const OTP_DAILY_MAX_PER_CONTACT = 5;
export function coachatronEmailFrom(): string {
return (process.env.COACHATRON_EMAIL_FROM ?? '').trim();
}

export const SMS_BRAND = 'Coachatron';
export const SMS_PURPOSE = 'booking confirmations, reminders, and session updates';
export const SMS_CONSENT_TEXT_VERSION = 'v1';
export const SUPPORT_EMAIL = process.env.SUPPORT_EMAIL ?? 'support@coachatron.com';
/** E.164 of the product SMS number. Empty until provisioned; the landing
* page hides the "text it" line when unset. Read at request time so tests
* can set the env without reloading the module. */
Expand Down
24 changes: 24 additions & 0 deletions src/db/migrations/0012_sms_consent.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
-- SMS consent and roster double opt-in (toll-free compliance).

create table if not exists sms_consent (
id serial primary key,
phone text not null,
purpose text not null,
consent_text_version text not null,
source text not null,
ip text,
user_agent text,
consented_at timestamptz not null default now(),
revoked_at timestamptz,
unique (phone, purpose)
);

create index if not exists sms_consent_phone on sms_consent (phone);

create table if not exists sms_consent_pending (
phone text primary key,
added_by_coach_id integer not null references coach(id),
added_for_label text not null,
purpose text not null,
sent_at timestamptz not null default now()
);
17 changes: 7 additions & 10 deletions src/domain/assistant.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { DbClient } from '../db/client.js';
import type { CoachRow } from './auth.js';
import { startCascade, isOptedOut } from './cascade.js';
import { startCascade } from './cascade.js';
import { summarizeMoney } from './money.js';
import { addCalendarDays, zonedParts, zonedTimeToUtc } from './scheduling.js';
import { APP_BASE_URL } from '../config.js';
Expand Down Expand Up @@ -542,14 +542,12 @@ async function executeCancel(db: DbClient, coach: CoachRow, sessionId: number):
await db.query('update credit set remaining = remaining + 1 where id = $1', [athlete.credit_id]);
credits += 1;
}
if (!(await isOptedOut(db, athlete.contact_phone))) {
await sendText(db, {
to: athlete.contact_phone,
body: `${athlete.athlete_name}'s ${row.name} on ${formatLocal(row.starts_at_utc, row.tz)} has been cancelled.`,
coachId: coach.id,
template: 'session-cancelled',
});
}
await sendText(db, {
to: athlete.contact_phone,
body: `${athlete.athlete_name}'s ${row.name} on ${formatLocal(row.starts_at_utc, row.tz)} has been cancelled.`,
coachId: coach.id,
template: 'session-cancelled',
});
}

await db.query("update session set status = 'cancelled' where id = $1", [sessionId]);
Expand Down Expand Up @@ -629,7 +627,6 @@ async function executeBroadcast(db: DbClient, coach: CoachRow, classified: Class
);
let sent = 0;
for (const athlete of athletes.rows) {
if (await isOptedOut(db, athlete.contact_phone)) continue;
const outcome = await sendText(db, { to: athlete.contact_phone, body, coachId: coach.id, template: 'broadcast' });
if (outcome === 'sent') sent += 1;
}
Expand Down
7 changes: 2 additions & 5 deletions src/domain/auth.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,12 @@
import crypto from 'node:crypto';
import type { DbClient } from '../db/client.js';
import { toE164 } from '../lib/phone.js';

const OTP_TTL_MINUTES = 10;
const SESSION_TTL_DAYS = 30;

export function normalizePhone(raw: string): string | null {
const digits = raw.replace(/[^0-9]/g, '');
if (digits.length === 10) return `+1${digits}`;
if (digits.length === 11 && digits.startsWith('1')) return `+${digits}`;
if (raw.startsWith('+') && digits.length >= 8 && digits.length <= 15) return `+${digits}`;
return null;
return toE164(raw);
}

/** Lowercased and trimmed, or null when it does not look like an address. */
Expand Down
39 changes: 31 additions & 8 deletions src/domain/cascade.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,25 @@ import { sendEmailMessage, sendText } from './outbound.js';
import { APP_BASE_URL } from '../config.js';
import { expireLivePendingForCoach } from './assistantPending.js';
import { clipSms, formatConfirmWhen } from '../lib/time.js';
import { SMS_PURPOSE } from '../config.js';
import { hasActiveConsent, queuePendingConsent } from './sms-consent.js';

export async function sendRosterConsentRequest(
db: DbClient,
coachId: number,
coachName: string,
phone: string,
): Promise<void> {
await queuePendingConsent(db, phone, coachId, coachName);
await sendText(db, {
to: phone,
coachId,
template: 'consent-request',
body: clipSms(
`Coachatron: ${coachName} added this number for ${SMS_PURPOSE}. Reply YES to receive these texts. Reply STOP to opt out.`,
),
});
}

export const OVERFLOW_OFFER_TTL_MINUTES = 20;

Expand All @@ -16,11 +35,9 @@ export async function upsertOptOut(db: DbClient, phone: string): Promise<void> {
await db.query('insert into opt_out (phone) values ($1) on conflict (phone) do nothing', [phone]);
}

/** All cascade-initiated (non-critical) sends go through this, never the
* bare relay client, so an opted-out number is silently skipped rather than
* texted again. SPEC.md §10: "STOP handling ... required, not optional." */
/** All cascade-initiated (non-critical) sends go through sendText, which
* enforces opt-out and consent. */
async function sendUnlessOptedOut(db: DbClient, coachId: number, template: string, to: string, body: string): Promise<void> {
if (await isOptedOut(db, to)) return;
await sendText(db, { to, body, coachId, template });
}

Expand Down Expand Up @@ -198,12 +215,19 @@ export async function startCascade(db: DbClient, sessionId: number): Promise<voi
where o.roster_member_id = rm.id
and o.session_id = $2
)
order by rm.priority asc, rm.id asc
limit 1`,
order by rm.priority asc, rm.id asc`,
[info.coachId, sessionId],
);

if (candidates.rows.length === 0) {
let member: { id: number; phone: string } | null = null;
for (const row of candidates.rows) {
if (await hasActiveConsent(db, row.phone)) {
member = row;
break;
}
}

if (!member) {
const alreadyNotified = await db.query<{ id: number }>(
'select id from overflow_ask where session_id = $1 and exhausted_notified_at is not null',
[sessionId],
Expand All @@ -224,7 +248,6 @@ export async function startCascade(db: DbClient, sessionId: number): Promise<voi
return;
}

const member = candidates.rows[0];
const expiresAt = new Date(Date.now() + OVERFLOW_OFFER_TTL_MINUTES * 60 * 1000);
const token = randomBytes(16).toString('hex');

Expand Down
41 changes: 34 additions & 7 deletions src/domain/outbound.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,15 @@ import { sendSms } from '../relay/sms.js';
import { sendEmail } from '../relay/email.js';
import { zonedParts, zonedTimeToUtc } from './scheduling.js';
import {
SMS_BRAND,
SMS_COACH_DAILY_CAP,
SMS_COACH_MONTHLY_CAP,
SMS_PRODUCT_DAILY_FLOOR,
SMS_PRODUCT_DAILY_PER_COACH,
} from '../config.js';
import { hasActiveConsent } from './sms-consent.js';
import { isOptedOut } from './cascade.js';
import { revokeConsent } from './sms-consent.js';

/** The only way Coachatron sends a text. SPEC.md §10: "The stop is there
* for a loop." Every send is one segment, domestic, logged, and counted
Expand Down Expand Up @@ -107,6 +111,14 @@ export interface OutboundText {
perRecipientDailyMax?: number;
}

const CONSENT_EXEMPT_TEMPLATES = new Set(['consent-request']);

function withBrand(body: string): string {
const prefix = `${SMS_BRAND}:`;
if (body.startsWith(prefix)) return body;
return `${prefix} ${body}`;
}

type Channel = 'sms' | 'email';

/** Counts sent rows on one channel; SMS ceilings never count emails. */
Expand Down Expand Up @@ -170,25 +182,40 @@ async function log(
}

export async function sendText(db: DbClient, msg: OutboundText, now: Date = new Date()): Promise<SendOutcome> {
const body = toOneSegment(msg.body);
const body = toOneSegment(withBrand(msg.body));
if (!canText(msg.to)) {
await log(db, msg, body, 'refused-destination', null, now);
return 'refused';
}
if (!CONSENT_EXEMPT_TEMPLATES.has(msg.template)) {
if (await isOptedOut(db, msg.to)) {
await log(db, msg, body, 'refused-opt-out', null, now);
return 'refused';
}
if (!(await hasActiveConsent(db, msg.to))) {
await log(db, msg, body, 'refused-no-consent', null, now);
return 'refused';
}
}
const hit = await ceilingHit(db, msg, now);
if (hit) {
await log(db, msg, body, `capped-${hit}`, null, now);
console.warn(`sms: ${hit} ceiling reached, not sending ${msg.template} (coach ${msg.coachId ?? '-'})`);
return 'capped';
}
try {
const sent = await sendSms({ to: msg.to, body });
await log(db, msg, body, 'sent', sent.id ?? null, now);
const sent = await sendSms({ to: msg.to, body });
if (sent.ok) {
await log(db, msg, body, 'sent', sent.id, now);
return 'sent';
} catch (err) {
await log(db, msg, body, 'failed', null, now);
throw err;
}
if (sent.code === 21610) {
await revokeConsent(db, msg.to);
await log(db, msg, body, 'failed-opt-out', null, now);
console.warn(`sms: recipient opted out (21610), not sending ${msg.template} to ${msg.to}`);
return 'refused';
}
await log(db, msg, body, 'failed', null, now);
throw new Error(`relay sms send failed: ${sent.code} ${sent.message}`);
}

export interface OutboundEmail {
Expand Down
91 changes: 91 additions & 0 deletions src/domain/sms-consent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import type { DbClient } from '../db/client.js';
import { SMS_CONSENT_TEXT_VERSION, SMS_PURPOSE } from '../config.js';
import { upsertOptOut, isOptedOut } from './cascade.js';

export { isOptedOut };

export async function hasActiveConsent(db: DbClient, phone: string, purpose: string = SMS_PURPOSE): Promise<boolean> {
if (await isOptedOut(db, phone)) return false;
const result = await db.query<{ id: number }>(
`select id from sms_consent
where phone = $1 and purpose = $2 and revoked_at is null`,
[phone, purpose],
);
return result.rows.length > 0;
}

export async function recordConsent(
db: DbClient,
phone: string,
source: string,
meta: { ip?: string | null; userAgent?: string | null; purpose?: string },
): Promise<void> {
const purpose = meta.purpose ?? SMS_PURPOSE;
await db.query(
`insert into sms_consent (phone, purpose, consent_text_version, source, ip, user_agent, consented_at, revoked_at)
values ($1, $2, $3, $4, $5, $6, now(), null)
on conflict (phone, purpose) do update set
consent_text_version = excluded.consent_text_version,
source = excluded.source,
ip = excluded.ip,
user_agent = excluded.user_agent,
consented_at = now(),
revoked_at = null`,
[phone, purpose, SMS_CONSENT_TEXT_VERSION, source, meta.ip ?? null, meta.userAgent ?? null],
);
await db.query('delete from opt_out where phone = $1', [phone]);
}

export async function revokeConsent(db: DbClient, phone: string, purpose: string = SMS_PURPOSE): Promise<void> {
await upsertOptOut(db, phone);
await db.query(
`update sms_consent set revoked_at = now() where phone = $1 and purpose = $2 and revoked_at is null`,
[phone, purpose],
);
}

export async function clearRevocation(db: DbClient, phone: string, purpose: string = SMS_PURPOSE): Promise<void> {
await db.query('delete from opt_out where phone = $1', [phone]);
await db.query(
`update sms_consent set revoked_at = null where phone = $1 and purpose = $2`,
[phone, purpose],
);
}

export async function queuePendingConsent(
db: DbClient,
phone: string,
coachId: number,
addedForLabel: string,
purpose: string = SMS_PURPOSE,
): Promise<void> {
await db.query(
`insert into sms_consent_pending (phone, added_by_coach_id, added_for_label, purpose, sent_at)
values ($1, $2, $3, $4, now())
on conflict (phone) do update set
added_by_coach_id = excluded.added_by_coach_id,
added_for_label = excluded.added_for_label,
purpose = excluded.purpose,
sent_at = now()`,
[phone, coachId, addedForLabel, purpose],
);
}

export async function hasPendingConsent(db: DbClient, phone: string): Promise<boolean> {
const result = await db.query<{ phone: string }>('select phone from sms_consent_pending where phone = $1', [phone]);
return result.rows.length > 0;
}

export async function confirmConsentFromReply(db: DbClient, phone: string): Promise<boolean> {
const pending = await db.query<{ purpose: string }>('select purpose from sms_consent_pending where phone = $1', [phone]);
if (pending.rows.length === 0) return false;
const purpose = pending.rows[0].purpose;
await recordConsent(db, phone, 'reply-yes', { purpose });
await db.query('delete from sms_consent_pending where phone = $1', [phone]);
return true;
}

/** Test helper and seeds: grant consent without a form POST. */
export async function grantSmsConsent(db: DbClient, phone: string, source = 'test'): Promise<void> {
await recordConsent(db, phone, source, {});
}
Loading