From a1c0be33242aeb4b77ae2bb56a04dc30db77b234 Mon Sep 17 00:00:00 2001 From: Ricardo Vega Date: Thu, 1 Oct 2026 15:13:32 -0500 Subject: [PATCH] Put a week box on the home page so a coach can start from one sentence. The stub at / is a Postcard marketing page. Typed text rides a cookie through phone sign-in; a stranger's text gets a one-time link. The model runs only after the phone is proven, and nothing goes live until Publish. Co-authored-by: Cursor --- SPEC.md | 16 +- docs/PLATFORM.md | 3 +- src/config.ts | 6 + src/db/migrations/0010_start_link.sql | 12 + src/domain/startLink.ts | 64 ++++++ src/lib/html.ts | 260 +++++++++++++++++++++ src/lib/startCookie.ts | 34 +++ src/routes/coach.ts | 43 +++- src/routes/landing.ts | 210 +++++++++++++++++ src/routes/webhooks.ts | 22 +- src/server.ts | 16 +- test/assistant.test.ts | 2 +- test/coach-onboarding.test.ts | 6 +- test/journeys.test.ts | 3 +- test/landing.test.ts | 313 ++++++++++++++++++++++++++ test/overflow-cascade.test.ts | 2 +- 16 files changed, 974 insertions(+), 38 deletions(-) create mode 100644 src/db/migrations/0010_start_link.sql create mode 100644 src/domain/startLink.ts create mode 100644 src/lib/startCookie.ts create mode 100644 src/routes/landing.ts create mode 100644 test/landing.test.ts diff --git a/SPEC.md b/SPEC.md index bef9cc2..a9735a8 100644 --- a/SPEC.md +++ b/SPEC.md @@ -135,14 +135,14 @@ the solo coach. They return only when a paying customer is blocked without them. ### 7.1 Coach onboarding (target: under 5 minutes) -1. Lands on `coachatron.com`, taps **Start**. -2. Enters name, phone, email. SMS one-time code verifies the phone. No password. -3. Says their week (the phone keyboard's mic, or typing) into one box on the - schedule screen, or texts it to the Coachatron number: *what it's called, how - long, how many athletes, how much, which days and times*. The product shows - what it understood, asks for anything missing (never guesses a price), and - writes nothing until the coach taps **Publish**. *(Added 2026-09-30. The one- - form session type and the weekly grid below remain as the by-hand fallback.)* +1. Lands on `coachatron.com` and types or texts their week: *what it's called, how + long, how many athletes, how much, which days and times*. +2. Enters name and phone. SMS one-time code verifies the phone (or they tap the + link we texted). No password. The model runs only after the phone is proven. +3. The product shows what it understood, asks for anything missing (never guesses + a price), and writes nothing until the coach taps **Publish**. *(Added 2026-09-30. + The landing box and the texted week feed this same setup. The one-form session + type and the weekly grid below remain as the by-hand fallback.)* Fallback: creates a session type in one form. Every other field is defaulted. 4. Picks recurring times on a weekly grid (tap the slots). Sessions are generated 8 weeks ahead, rolling. *(Implemented: each weekly time is a `weekly_slot`, diff --git a/docs/PLATFORM.md b/docs/PLATFORM.md index cd2e1e8..2ca5848 100644 --- a/docs/PLATFORM.md +++ b/docs/PLATFORM.md @@ -212,7 +212,8 @@ These are the whole design. Without them this feature is a liability. "I didn't catch that" plus a deep link. Never guess a destructive action. - **R4 — Coach-only.** NL control is authorized by the coach's verified phone number. Athlete and roster numbers reach only the keyword layer. An unknown - number gets the public booking link and nothing else. + number never reaches the model: a stranger gets one sign-up link a day, and a + known parent gets the booking link. - **R5 — Bounded blast radius.** One intent affects one session or one broadcast. No "cancel everything next week" in Slice 1 — multi-entity operations go to the web UI. diff --git a/src/config.ts b/src/config.ts index bf817c5..3090d85 100644 --- a/src/config.ts +++ b/src/config.ts @@ -31,3 +31,9 @@ export const SMS_COACH_MONTHLY_CAP = 2000; export const SMS_PRODUCT_DAILY_PER_COACH = 400; export const SMS_PRODUCT_DAILY_FLOOR = 500; export const OTP_DAILY_MAX_PER_PHONE = 5; +/** 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. */ +export function coachatronSmsNumber(): string { + return (process.env.COACHATRON_SMS_NUMBER ?? '').trim(); +} diff --git a/src/db/migrations/0010_start_link.sql b/src/db/migrations/0010_start_link.sql new file mode 100644 index 0000000..7a98477 --- /dev/null +++ b/src/db/migrations/0010_start_link.sql @@ -0,0 +1,12 @@ +-- A stranger texts their week. We store it against a one-time link and +-- never call the model until they tap it (so the phone is proven). +create table if not exists start_link ( + id serial primary key, + token_hash text not null unique, + phone text not null, + text text not null, + expires_at timestamptz not null, + used_at timestamptz, + created_at timestamptz not null default now() +); +create index if not exists start_link_phone on start_link (phone); diff --git a/src/domain/startLink.ts b/src/domain/startLink.ts new file mode 100644 index 0000000..53fd643 --- /dev/null +++ b/src/domain/startLink.ts @@ -0,0 +1,64 @@ +import { createHash, randomBytes } from 'node:crypto'; +import type { DbClient } from '../db/client.js'; +import { clipStartText } from '../lib/startCookie.js'; + +const START_LINK_TTL_MS = 24 * 60 * 60 * 1000; + +export interface StartLinkRow { + phone: string; + text: string; +} + +function tokenHash(token: string): string { + return createHash('sha256').update(token).digest('hex'); +} + +export async function createStartLink( + db: DbClient, + phone: string, + text: string, + now: Date = new Date(), +): Promise { + const token = randomBytes(16).toString('hex'); + const expiresAt = new Date(now.getTime() + START_LINK_TTL_MS); + await db.query('insert into start_link (token_hash, phone, text, expires_at) values ($1, $2, $3, $4)', [ + tokenHash(token), + phone, + clipStartText(text), + expiresAt.toISOString(), + ]); + return token; +} + +export async function getOpenStartLink( + db: DbClient, + token: string, + now: Date = new Date(), +): Promise { + const result = await db.query<{ phone: string; text: string; used_at: string | null; expires_at: string }>( + 'select phone, text, used_at, expires_at from start_link where token_hash = $1', + [tokenHash(token)], + ); + const row = result.rows[0]; + if (!row || row.used_at || new Date(row.expires_at) <= now) return null; + return { phone: row.phone, text: row.text }; +} + +export async function consumeStartLink( + db: DbClient, + token: string, + now: Date = new Date(), +): Promise { + const result = await db.query( + `update start_link set used_at = $2 + where token_hash = $1 and used_at is null and expires_at > $2 + returning phone, text`, + [tokenHash(token), now.toISOString()], + ); + return result.rows[0] ?? null; +} + +export async function phoneHasBooking(db: DbClient, phone: string): Promise { + const result = await db.query('select 1 from booking where contact_phone = $1 limit 1', [phone]); + return result.rows.length > 0; +} diff --git a/src/lib/html.ts b/src/lib/html.ts index 9a6e932..9964b67 100644 --- a/src/lib/html.ts +++ b/src/lib/html.ts @@ -297,3 +297,263 @@ export function page(title: string, body: SafeHtml): string { `; } + +/** Marketing page at `/`. Same Postcard tokens as the app, but no phone-shell + * wrapper — the healthcheck stays a static 200 with no database. */ +export const LANDING_CSS = ` + :root { + --page: #f6e7d8; + --ink: #2c211c; + --muted: #6d5348; + --teal: #0e6b64; + --teal-ink: #f6fffd; + --clay: #c46a45; + --clay-line: #a85634; + --screen: #fffaf4; + --screen-ink: #2c211c; + --screen-muted: #7a655b; + --card: #fff; + --line: #eddccb; + --good: #0e6b64; + --touch-min: 44px; + } + * { box-sizing: border-box; } + html, body { margin: 0; } + body { + background: var(--page); + color: var(--ink); + font-family: "Iowan Old Style", Palatino, Georgia, serif; + line-height: 1.4; + } + .wrap { max-width: 1100px; margin: 0 auto; padding: 28px 20px 64px; } + .kicker { + font-family: ui-sans-serif, system-ui, sans-serif; + font-size: 12px; + letter-spacing: 0.16em; + text-transform: uppercase; + color: var(--teal); + } + h1 { + font-weight: 500; + font-size: clamp(2.4rem, 8vw, 4.6rem); + line-height: 0.95; + letter-spacing: -0.03em; + margin: 12px 0 14px; + max-width: 14ch; + } + .lede { + max-width: 36rem; + font-size: 1.2rem; + margin: 0 0 22px; + } + .hero { + display: grid; + gap: 32px; + align-items: start; + } + .box label { + display: block; + font-family: ui-sans-serif, system-ui, sans-serif; + font-size: 0.85rem; + color: var(--muted); + margin-bottom: 6px; + } + textarea { + width: 100%; + min-height: 8.5rem; + padding: 12px 14px; + border-radius: 14px; + border: 1px solid var(--line); + background: var(--card); + color: var(--ink); + font-family: ui-sans-serif, system-ui, sans-serif; + font-size: 16px; + line-height: 1.4; + resize: vertical; + } + button, .action { + display: inline-block; + margin-top: 12px; + width: 100%; + min-height: var(--touch-min); + text-align: center; + background: var(--teal); + color: var(--teal-ink); + border: 0; + border-radius: 999px; + padding: 12px 16px; + font-family: ui-sans-serif, system-ui, sans-serif; + font-size: 1rem; + font-weight: 700; + cursor: pointer; + text-decoration: none; + line-height: 1.2; + } + .hint, .sms, .fine, .skip p, .price p, footer a { + font-family: ui-sans-serif, system-ui, sans-serif; + } + .hint { margin: 8px 0 0; color: var(--muted); font-size: 0.9rem; } + .sms { margin: 14px 0 0; font-size: 1rem; } + .sms a { color: var(--teal); font-weight: 600; } + .steps, .skip, .price { margin-top: 36px; max-width: 36rem; } + .steps h2, .skip h2, .price h2 { + font-weight: 500; + font-size: 1.6rem; + letter-spacing: -0.03em; + margin: 0 0 12px; + } + .steps ol { margin: 0; padding: 0; list-style: none; counter-reset: step; } + .steps li { + counter-increment: step; + background: var(--card); + border: 1px solid var(--line); + border-radius: 18px; + padding: 14px 16px 14px 52px; + margin-bottom: 10px; + position: relative; + } + .steps li::before { + content: counter(step); + position: absolute; + left: 14px; + top: 14px; + width: 26px; + height: 26px; + border-radius: 999px; + background: var(--teal); + color: var(--teal-ink); + font-family: ui-sans-serif, system-ui, sans-serif; + font-size: 13px; + font-weight: 700; + display: flex; + align-items: center; + justify-content: center; + } + .steps strong { display: block; font-size: 1.05rem; } + .steps span { + display: block; + margin-top: 4px; + color: var(--muted); + font-family: ui-sans-serif, system-ui, sans-serif; + font-size: 0.95rem; + } + .skip ul { margin: 0; padding: 0; list-style: none; } + .skip li { + font-family: ui-sans-serif, system-ui, sans-serif; + padding: 8px 0; + border-bottom: 1px solid var(--line); + } + .skip li:last-child { border-bottom: 0; } + .price p, .skip p { color: var(--muted); margin: 0; } + .stage { display: none; } + .phone { + width: 320px; + height: 660px; + background: var(--clay); + border: 1px solid var(--clay-line); + border-radius: 40px; + padding: 10px; + } + .screen { + height: 100%; + background: var(--screen); + color: var(--screen-ink); + border-radius: 32px; + overflow: hidden; + display: flex; + flex-direction: column; + font-family: ui-sans-serif, system-ui, sans-serif; + } + .status { + display: flex; + justify-content: space-between; + padding: 14px 18px 0; + font-size: 12px; + font-weight: 650; + } + .body { + padding: 6px 16px 16px; + display: flex; + flex-direction: column; + gap: 10px; + flex: 1; + min-height: 0; + } + .eyebrow { + font-size: 10px; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--screen-muted); + margin: 6px 0 0; + } + .screen h2 { + font-family: "Iowan Old Style", Palatino, Georgia, serif; + font-weight: 500; + font-size: 30px; + line-height: 1; + letter-spacing: -0.03em; + margin: 0; + } + .sub { margin: 0; color: var(--screen-muted); font-size: 13px; } + .card { + border: 1px solid var(--line); + border-radius: 18px; + padding: 12px; + background: var(--card); + } + .card strong { display: block; font-size: 15px; } + .meta { + display: flex; + justify-content: space-between; + gap: 8px; + margin-top: 6px; + color: var(--screen-muted); + font-size: 13px; + } + .spots { color: var(--good); font-weight: 700; } + .btn { + margin-top: auto; + border: 0; + background: var(--teal); + color: var(--teal-ink); + border-radius: 999px; + padding: 14px 12px; + font: inherit; + font-size: 16px; + font-weight: 700; + text-align: center; + } + footer.colophon { + margin-top: 48px; + padding-top: 16px; + border-top: 1px solid #e4d0bc; + font-size: 0.95rem; + color: var(--muted); + } + footer.colophon p { margin: 0 0 8px; } + footer.colophon a { color: var(--teal); } + @media (min-width: 900px) { + .wrap { padding: 40px 28px 80px; } + .hero { grid-template-columns: 1fr 320px; gap: 48px; } + .stage { display: flex; justify-content: center; } + } +`; + +export function landingPage(title: string, description: string, body: SafeHtml): string { + return ` + + + + +${escapeHtml(title)} + + + + + + + +${body.value} + +`; +} diff --git a/src/lib/startCookie.ts b/src/lib/startCookie.ts new file mode 100644 index 0000000..7c62469 --- /dev/null +++ b/src/lib/startCookie.ts @@ -0,0 +1,34 @@ +/** The typed week from the landing page, carried through sign-in as an + * HttpOnly cookie so the model never runs for an anonymous visitor. */ + +import { parseCookies } from './cookies.js'; + +export const START_COOKIE = 'cx_start'; +export const START_COOKIE_MAX_AGE = 60 * 60; +export const START_TEXT_MAX_BYTES = 2400; + +export function clipStartText(text: string): string { + const trimmed = text.trim(); + const buf = Buffer.from(trimmed, 'utf8'); + if (buf.length <= START_TEXT_MAX_BYTES) return trimmed; + let end = START_TEXT_MAX_BYTES; + while (end > 0 && (buf[end] & 0xc0) === 0x80) end -= 1; + return buf.subarray(0, end).toString('utf8'); +} + +export function encodeStartCookie(text: string): string { + return Buffer.from(clipStartText(text), 'utf8').toString('base64url'); +} + +export function decodeStartCookie(value: string | undefined): string { + if (!value) return ''; + try { + return clipStartText(Buffer.from(value, 'base64url').toString('utf8')); + } catch { + return ''; + } +} + +export function readStartText(cookieHeader: string | undefined): string { + return decodeStartCookie(parseCookies(cookieHeader)[START_COOKIE]); +} diff --git a/src/routes/coach.ts b/src/routes/coach.ts index 3060f04..a6aba42 100644 --- a/src/routes/coach.ts +++ b/src/routes/coach.ts @@ -22,7 +22,8 @@ import { DEFAULT_TZ, formatLocal, isValidTimeZone } from '../lib/time.js'; import { getPackagesForCoach, getPlansForCoach, createPackage, createPlan } from '../domain/pricing.js'; import { summarizeMoney } from '../domain/money.js'; import { handleCoachMessage, loadScheduleAssistant, resolvePendingForCoach } from '../domain/assistant.js'; -import { parseCookies, serializeCookie } from '../lib/cookies.js'; +import { clearCookie, parseCookies, serializeCookie } from '../lib/cookies.js'; +import { readStartText, START_COOKIE } from '../lib/startCookie.js'; import { coachHasSchedule, discardDraft, @@ -92,11 +93,11 @@ async function requireAuth(req: Request, res: Response, next: NextFunction): Pro // ---- Screen 1: sign in ---- -function renderPhoneForm(value = '', error?: string) { +function renderPhoneForm(value = '', error?: string, saved = false) { return page( 'Sign in', html`

Coachatron

-

Enter your phone number to sign in or sign up.

+

${saved ? "Saved. Now your phone. We'll text a code, then show your week." : 'Enter your phone number to sign in or sign up.'}

@@ -106,15 +107,16 @@ function renderPhoneForm(value = '', error?: string) { ); } -coachRouter.get('/signin', (_req, res) => { - res.status(200).send(renderPhoneForm()); +coachRouter.get('/signin', (req, res) => { + res.status(200).send(renderPhoneForm('', undefined, Boolean(readStartText(req.headers.cookie)))); }); coachRouter.post('/signin/otp', async (req, res) => { + const saved = Boolean(readStartText(req.headers.cookie)); const raw_phone = field(req.body, 'phone'); const phone = normalizePhone(raw_phone); if (!phone) { - res.status(422).send(renderPhoneForm(raw_phone, 'Enter a valid phone number.')); + res.status(422).send(renderPhoneForm(raw_phone, 'Enter a valid phone number.', saved)); return; } const db = getDb(); @@ -135,6 +137,7 @@ coachRouter.post('/signin/otp', async (req, res) => { outcome === 'refused' ? 'Use a US or Canadian mobile number.' : 'Too many codes for this number today. Try again tomorrow.', + saved, ), ); return; @@ -208,7 +211,19 @@ coachRouter.post('/signin/verify', async (req, res) => { } const token = await createCoachSession(db, coach.id); - res.setHeader('Set-Cookie', serializeCookie(SESSION_COOKIE, token, 30 * 24 * 60 * 60)); + const cookies = [serializeCookie(SESSION_COOKIE, token, 30 * 24 * 60 * 60)]; + const startText = readStartText(req.headers.cookie); + if (startText) { + const result = await handleSetupMessage(db, coach, startText, 'web'); + if (result.kind === 'draft') { + cookies.push(clearCookie(START_COOKIE)); + } + res.setHeader('Set-Cookie', cookies); + const error = result.kind === 'cap' ? '&e=cap' : result.kind === 'error' ? '&e=read' : ''; + res.redirect(303, `/app/schedule?setup=1${error}`); + return; + } + res.setHeader('Set-Cookie', cookies); res.redirect(303, '/app/schedule'); }); @@ -417,7 +432,13 @@ const SETUP_NOTICES: Record = { const SETUP_EXAMPLE = 'Keeper group Tuesdays and Thursdays at 6 at Field 3, an hour, 8 kids, $35. Backup coach gets $80. Privates Saturday at 9, 10 and 11, 45 minutes, $70. 10-pack for $300.'; -function renderSetupScreen(coach: CoachRow, draft: SetupDraft | null, notice: string | null, hasSchedule: boolean) { +function renderSetupScreen( + coach: CoachRow, + draft: SetupDraft | null, + notice: string | null, + hasSchedule: boolean, + startText = '', +) { const questions = draft?.questions ?? []; const ready = draft !== null && questions.length === 0; const label = !draft ? 'Tell me your week' : questions.length > 0 ? questions[0] : 'Change anything? Just say it.'; @@ -441,7 +462,7 @@ function renderSetupScreen(coach: CoachRow, draft: SetupDraft | null, notice: st : raw('')} - + ${draft @@ -491,7 +512,9 @@ coachRouter.get('/app/schedule', requireAuth, async (req, res) => { if (!hasSchedule || req.query.setup === '1') { const code = typeof req.query.e === 'string' ? req.query.e : ''; const notice = Object.hasOwn(SETUP_NOTICES, code) ? SETUP_NOTICES[code] : null; - res.status(200).send(renderSetupScreen(coach, await getOpenDraft(db, coachId, new Date()), notice, hasSchedule)); + const draft = await getOpenDraft(db, coachId, new Date()); + const startText = draft ? '' : readStartText(req.headers.cookie); + res.status(200).send(renderSetupScreen(coach, draft, notice, hasSchedule, startText)); return; } const live = req.query.published === '1' ? renderLiveCard(coach, await paymentsConnected(coach)) : raw(''); diff --git a/src/routes/landing.ts b/src/routes/landing.ts new file mode 100644 index 0000000..ffe0ada --- /dev/null +++ b/src/routes/landing.ts @@ -0,0 +1,210 @@ +import { Router } from 'express'; +import { coachatronSmsNumber } from '../config.js'; +import { getDb } from '../db/client.js'; +import { + createCoach, + createCoachSession, + findCoachByPhone, + normalizePhone, + type CoachRow, +} from '../domain/auth.js'; +import { consumeStartLink, getOpenStartLink } from '../domain/startLink.js'; +import { handleSetupMessage } from '../domain/setup.js'; +import { html, landingPage, page, raw } from '../lib/html.js'; +import { clearCookie, serializeCookie } from '../lib/cookies.js'; +import { + encodeStartCookie, + START_COOKIE, + START_COOKIE_MAX_AGE, +} from '../lib/startCookie.js'; +import { DEFAULT_TZ, isValidTimeZone } from '../lib/time.js'; + +const SESSION_COOKIE = 'cx_session'; + +export const landingRouter = Router(); + +const TITLE = 'Say your week. Start taking bookings.'; +const DESCRIPTION = + 'Type it or talk it — what you coach, how long, how many, the price, your days. Get a link parents book and pay on. About five minutes, on your phone.'; +const WEEK_EXAMPLE = + 'Tuesdays and Thursdays at 6, an hour, 8 athletes, $35. Privates Saturday at 9, 45 minutes, $70. 10-pack for $300.'; + +function field(body: unknown, key: string): string { + const value = (body as Record | undefined)?.[key]; + return typeof value === 'string' ? value.trim() : ''; +} + +function displaySmsNumber(raw: string): { href: string; label: string } | null { + const phone = normalizePhone(raw); + if (!phone) return null; + const label = + phone.startsWith('+1') && phone.length === 12 + ? `(${phone.slice(2, 5)}) ${phone.slice(5, 8)}-${phone.slice(8)}` + : phone; + return { href: `sms:${phone}`, label }; +} + +function renderLanding(): string { + const sms = displaySmsNumber(coachatronSmsNumber()); + return landingPage( + `${TITLE} — Coachatron`, + DESCRIPTION, + html`
+
+
+
Coachatron
+

Say your week. Start taking bookings.

+

${DESCRIPTION}

+
+ + +

Tap the mic on your keyboard and just talk.

+ +
+ ${sms + ? html`

Or text it to ${sms.label}.

` + : raw('')} +
+ +
+
+

Three steps. Then you're live.

+
    +
  1. Say itOne box, or one text. What you coach, how long, how many, the price, your days.
  2. +
  3. Check itWe show what we understood and ask about anything missing. We never guess a price. Nothing goes live until you tap Publish.
  4. +
  5. Share itParents pick a time and pay. No app. No account.
  6. +
+
+
+

What you skip

+
    +
  • No setup wizard.
  • +
  • No passwords. Your phone is the sign-in.
  • +
  • No app to download.
  • +
+
+
+

Free to start. 5% when they pay.

+

Money goes straight to your own account. We never hold it. Card processing is extra, on top.

+
+ +
`, + ); +} + +landingRouter.get('/', (_req, res) => { + res.status(200).send(renderLanding()); +}); + +landingRouter.post('/start', (req, res) => { + const text = field(req.body, 'text'); + if (!text) { + res.setHeader('Set-Cookie', clearCookie(START_COOKIE)); + res.redirect(303, '/signin'); + return; + } + res.setHeader('Set-Cookie', serializeCookie(START_COOKIE, encodeStartCookie(text), START_COOKIE_MAX_AGE)); + res.redirect(303, '/signin'); +}); + +const TZ_SCRIPT = raw( + ``, +); + +function gonePage(): string { + return page( + 'Link expired', + html`

That link is done.

+

Start again from the home page.

+ Say your week`, + ); +} + +function renderStartForm( + token: string, + values: { name?: string; email?: string; tz?: string }, + error?: string, +): string { + return page( + 'Your name', + html`

Your name

+

We already have your phone from the text. Then we'll show your week.

+
+ + + + + + +
+ ${error ? html`

${error}

` : raw('')} + ${TZ_SCRIPT}`, + ); +} + +landingRouter.get('/start/:token', async (req, res) => { + const token = typeof req.params.token === 'string' ? req.params.token : ''; + const link = await getOpenStartLink(getDb(), token); + if (!link) { + res.status(410).send(gonePage()); + return; + } + res.status(200).send(renderStartForm(token, {})); +}); + +landingRouter.post('/start/:token', async (req, res) => { + const token = typeof req.params.token === 'string' ? req.params.token : ''; + const name = field(req.body, 'name'); + const email = field(req.body, 'email'); + const tz = field(req.body, 'tz'); + const db = getDb(); + const open = await getOpenStartLink(db, token); + if (!open) { + res.status(410).send(gonePage()); + return; + } + const existing = await findCoachByPhone(db, open.phone); + if (!existing && !name) { + res.status(422).send(renderStartForm(token, { name, email, tz }, 'Enter your name.')); + return; + } + const consumed = await consumeStartLink(db, token); + if (!consumed) { + res.status(410).send(gonePage()); + return; + } + let coach: CoachRow | null = existing; + if (!coach) { + coach = await createCoach(db, { + phone: consumed.phone, + name, + email, + tz: isValidTimeZone(tz) ? tz : DEFAULT_TZ, + }); + } + const session = await createCoachSession(db, coach.id); + const cookies = [serializeCookie(SESSION_COOKIE, session, 30 * 24 * 60 * 60)]; + const result = await handleSetupMessage(db, coach, consumed.text, 'web'); + if (result.kind !== 'draft') { + cookies.push(serializeCookie(START_COOKIE, encodeStartCookie(consumed.text), START_COOKIE_MAX_AGE)); + } + res.setHeader('Set-Cookie', cookies); + const error = result.kind === 'cap' ? '&e=cap' : result.kind === 'error' ? '&e=read' : ''; + res.redirect(303, `/app/schedule?setup=1${error}`); +}); diff --git a/src/routes/webhooks.ts b/src/routes/webhooks.ts index 4ab9110..9df0528 100644 --- a/src/routes/webhooks.ts +++ b/src/routes/webhooks.ts @@ -12,6 +12,7 @@ import { } from '../domain/cascade.js'; import { handleCoachMessage, keywordToken } from '../domain/assistant.js'; import { getLivePending, logAssistant, takeDailyReplySlot } from '../domain/assistantPending.js'; +import { createStartLink, phoneHasBooking } from '../domain/startLink.js'; import { APP_BASE_URL } from '../config.js'; import { clipSms } from '../lib/time.js'; @@ -144,6 +145,25 @@ webhooksRouter.post('/webhooks/sms', async (req, res) => { return; } - await replyOnce(db, from, `Coachatron: use your booking link at ${APP_BASE_URL}`, now); + if (await phoneHasBooking(db, from)) { + await replyOnce(db, from, `Coachatron: use your booking link at ${APP_BASE_URL}`, now); + res.status(200).send('unrecognized'); + return; + } + + const ok = await takeDailyReplySlot(db, from, now); + if (ok) { + const token = await createStartLink(db, from, body, now); + await sendText( + db, + { + to: from, + body: `Got it. Tap to see your week and go live: ${APP_BASE_URL}/start/${token}`, + coachId: null, + template: 'auto-reply', + }, + now, + ); + } res.status(200).send('unrecognized'); }); diff --git a/src/server.ts b/src/server.ts index d474c42..f1cce10 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,5 +1,6 @@ import express from 'express'; import { PORT } from './config.js'; +import { landingRouter } from './routes/landing.js'; import { coachRouter } from './routes/coach.js'; import { publicRouter } from './routes/public.js'; import { webhooksRouter } from './routes/webhooks.js'; @@ -7,7 +8,6 @@ import { storeWebhookRouter } from './routes/store-webhook.js'; import { getDb } from './db/client.js'; import { runMigrations } from './db/migrate.js'; import { runTick } from './jobs/tick.js'; -import { html, page } from './lib/html.js'; const TICK_MS = 60_000; @@ -17,18 +17,8 @@ export function createApp() { app.use(express.urlencoded({ extended: true })); app.use(express.json()); - // Also the Railway healthcheck, so it never touches the database. - app.get('/', (_req, res) => { - res.status(200).send( - page( - 'Coachatron', - html`

Coachatron

-

Tell us your week. Get a link parents can book and pay on.

- Start`, - ), - ); - }); - + // GET / is also the Railway healthcheck, so it never touches the database. + app.use(landingRouter); app.use(coachRouter); app.use(publicRouter); app.use(webhooksRouter); diff --git a/test/assistant.test.ts b/test/assistant.test.ts index f25622b..88fd419 100644 --- a/test/assistant.test.ts +++ b/test/assistant.test.ts @@ -338,7 +338,7 @@ test('unknown numbers get one booking-link reply per day', async () => { }); const toThem = relay.sms.filter((m) => m.to === '+15559990000'); assert.equal(toThem.length, 1); - assert.match(toThem[0].body, /link/i); + assert.match(toThem[0].body, /\/start\//); assert.match(toThem[0].body, new RegExp(APP_BASE_URL.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))); }); }); diff --git a/test/coach-onboarding.test.ts b/test/coach-onboarding.test.ts index f8863b1..7e400a6 100644 --- a/test/coach-onboarding.test.ts +++ b/test/coach-onboarding.test.ts @@ -124,12 +124,14 @@ async function signUp(base: string, relay: { sms: Array<{ to: string; body: stri }); } -test('landing page has one Start link to sign in', async () => { +test('landing page has a week box and a sign-in link', async () => { await freshDb(); await withServer(async (base) => { const res = await fetch(`${base}/`); assert.equal(res.status, 200); - assert.match(await res.text(), /Start<\/a>/); + const body = await res.text(); + assert.match(body, /action="\/start"/); + assert.match(body, /href="\/signin">Sign in { // Decision 1: Start. const landing = await (await fetch(`${base}/`)).text(); - assert.match(landing, /href="\/signin">Start/); + assert.match(landing, /Set it up/); + assert.match(landing, /href="\/signin">Sign in c.startsWith(`${name}=`)); +} + +function cookieNamed(res: Response, name: string): string | null { + const header = setCookieHeader(res, name); + if (!header) return null; + const match = /^[^=]+=([^;]*)/.exec(header); + return match ? `${name}=${match[1]}` : null; +} + +function joinCookies(...parts: Array): string { + return parts.filter((p): p is string => Boolean(p)).join('; '); +} + +async function smsTo(base: string, from: string, body: string): Promise { + return fetch(`${base}/webhooks/sms`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ from, body }), + }); +} + +async function signInWithStart( + base: string, + relay: { sms: Array<{ to: string; body: string }> }, + digits: string, + startCookie: string, + profile: Record, +): Promise { + await fetch(`${base}/signin/otp`, { + method: 'POST', + headers: { 'content-type': 'application/json', cookie: startCookie }, + body: JSON.stringify({ phone: digits }), + redirect: 'manual', + }); + const phone = `+1${digits}`; + const code = /code is (\d{6})/.exec(relay.sms.filter((m) => m.to === phone).at(-1)!.body)![1]; + return fetch(`${base}/signin/verify`, { + method: 'POST', + headers: { 'content-type': 'application/json', cookie: startCookie }, + body: JSON.stringify({ phone, code, ...profile }), + redirect: 'manual', + }); +} + +test('GET / returns 200 with no database, and the box posts to /start', async () => { + await withServer(async (base) => { + const res = await fetch(`${base}/`); + assert.equal(res.status, 200); + const body = await res.text(); + assert.match(body, /Say your week/); + assert.match(body, /action="\/start"/); + assert.match(body, /