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
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,19 @@ SESSION_SECRET=
DATABASE_URL=
APP_BASE_URL=
APP_ENV=
# dev: the relay captures texts and emails in smtp4dev (mail.dev.noctusoft.com)
# instead of sending them. Leave unset on uat and production.
RELAY_APP_ENV=
# Sender for sign-in codes. Empty: the relay's shared sender. Set to
# no-reply@coachatron.com once coachatron.com is authenticated in SendGrid.
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
2 changes: 2 additions & 0 deletions .railway/railway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ export default defineRailway((ctx) => {
RELAY_BASE_URL: preserve(),
RELAY_CONNECT_PRODUCT: preserve(),
RELAY_INBOUND_SECRET: preserve(),
// dev only: texts and emails are captured in smtp4dev, never sent.
RELAY_APP_ENV: preserve(),
RELAY_WEBHOOK_SECRET: preserve(),
SESSION_SECRET: preserve(),
STORE_BASE_URL: preserve(),
Expand Down
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,17 @@ forwards Twilio's form fields to `/webhooks/sms`, signed with
number serves every environment for sending; incoming texts go to uat while
testing and to production at launch (`docs/PLATFORM.md` section 3).

## Testing texts without a phone (development)

Development runs with `RELAY_APP_ENV=dev`. The relay then captures every text
and email instead of sending it, in smtp4dev at
[mail.dev.noctusoft.com](https://mail.dev.noctusoft.com/) (IP-allowlisted). A
text to `+1 555 555 0100` shows up as an email to
`sms-15555550100@sms.capture.noctusoft.com`. Nothing reaches a phone, so any
number, including one outside the US and Canada, can sign in on development to
try the text flows. uat and production leave `RELAY_APP_ENV` unset and really
send.

## Texting ceilings

Every outbound text goes through `sendText()` in `src/domain/outbound.ts`
Expand Down
16 changes: 16 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -1282,3 +1282,19 @@ Still to do, outside this repo:
3. Railway uat: `RELAY_INBOUND_SECRET`, `INBOUND_SMS_URL`,
`COACHATRON_SMS_NUMBER`. Production follows at launch.

---

## M15: Texts captured on development, like smtp4dev for email
Status: [x] done
Goal: Try every text flow on development without a phone and without
texting anyone.
Acceptance:
- [x] Texts carry `X-App-Env` from `RELAY_APP_ENV`, like emails already did;
with `dev` the relay captures them in smtp4dev as
`sms-<digits>@sms.capture.noctusoft.com` (relay `sms-env.js`)
- [x] With `RELAY_APP_ENV=dev`, any number can get a text code, since the
relay skips its +1 rule for captured texts; otherwise +1 only
(`test/dev-sms-capture.test.ts`)
- [x] Development has `RELAY_APP_ENV=dev`; uat and production leave it unset
- [x] All five quality-bar commands still exit 0

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
56 changes: 48 additions & 8 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 @@ -81,6 +85,19 @@ export function isDomesticDestination(phone: string): boolean {
return /^\+1\d{10}$/.test(phone);
}

/** RELAY_APP_ENV=dev: the relay captures every text in the dev mail catcher
* (smtp4dev at mail.dev.noctusoft.com) instead of sending it, and skips its
* own caps there. Nothing reaches a phone, so any number can be tried. */
export function textsAreCaptured(): boolean {
return ['dev', 'development', 'local'].includes((process.env.RELAY_APP_ENV ?? '').trim().toLowerCase());
}

/** Whether a text to this number can go out: +1 numbers, or any number
* while texts are captured in dev. */
export function canText(phone: string): boolean {
return isDomesticDestination(phone) || (textsAreCaptured() && /^\+\d{8,15}$/.test(phone));
}

export type SendOutcome = 'sent' | 'capped' | 'refused';

export interface OutboundText {
Expand All @@ -94,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 @@ -157,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);
if (!isDomesticDestination(msg.to)) {
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
Loading