diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 91d22ae..65f0648 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,9 +23,12 @@ jobs: with: persist-credentials: false + # The required check. .nvmrc is the one place the project's Node + # version lives; Railway reads package.json engines (kept in step by + # test/toolchain.test.ts) and nvm reads .nvmrc locally. - uses: actions/setup-node@v7 with: - node-version: 22 + node-version-file: .nvmrc cache: npm # Fresh clone, no secrets. Tests use PGlite and in-process fakes @@ -44,3 +47,35 @@ jobs: - name: Build run: npm run build + + # Early warning, never blocking: the newest LTS and the newest Current + # release. When the next Node line becomes LTS, the lts/* job tests it + # before anyone moves .nvmrc and engines. + next-node: + name: Next Node (${{ matrix.node }}) + runs-on: ubuntu-24.04 + timeout-minutes: 10 + continue-on-error: true + strategy: + fail-fast: false + matrix: + node: ['lts/*', 'latest'] + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + + - uses: actions/setup-node@v7 + with: + node-version: ${{ matrix.node }} + check-latest: true + cache: npm + + - name: Install + run: npm ci + + - name: Typecheck + run: npm run typecheck + + - name: Test + run: npm test diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..3f52ff9 --- /dev/null +++ b/.npmrc @@ -0,0 +1,3 @@ +# Refuse to install on a Node outside package.json "engines" instead of +# failing later in a confusing way. .nvmrc names the version to use. +engine-strict=true diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..a45fd52 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +24 diff --git a/.railway/README.md b/.railway/README.md new file mode 100644 index 0000000..6476535 --- /dev/null +++ b/.railway/README.md @@ -0,0 +1,30 @@ +# Railway configuration + +`railway.ts` defines Coachatron's `web` service for every environment +(development from `develop`, uat from `staging`, production from `main`). It +replaced `railway.json`, which Railway stops reading on 2026-12-01. + +Railway does not read this folder on deploy. Changes take effect only when +someone applies them, one environment at a time, from the repo root on Node 24: + +```bash +railway environment development # then uat, then production +railway config plan # must say 0 to destroy +railway config apply +``` + +- **Partial `web`.** The file owns only the web service. Postgres, its volume, + and slack-cards are not in it and apply never touches them. +- **Variables.** Every value stays in Railway; the file marks each one + `preserve()`. Never put a secret here. +- **Node version.** `RAILPACK_NODE_VERSION` comes from `../.nvmrc`. Railpack + reads that variable before `package.json` engines, so it decides the Node a + deploy runs. To apply everything except a Node change, set + `COACHATRON_IAC_PRESERVE_NODE=1`. +- **Known Railway bug (production).** `railway config apply` with any change + fails in production with "Custom-domain registration is not supported" for + `coachatron.com` and `www.coachatron.com`, though both exist and `plan` + shows only the intended change. Until Railway fixes it, make the planned + change directly (for a variable: `railway variable set KEY=VALUE --service + web --environment production`), then run `railway config plan` to confirm + it says "already up to date". diff --git a/.railway/railway.ts b/.railway/railway.ts new file mode 100644 index 0000000..89722cc --- /dev/null +++ b/.railway/railway.ts @@ -0,0 +1,67 @@ +import { readFileSync } from "node:fs"; +import { defineRailway, github, preserve, project, service } from "railway/iac"; + +// Coachatron's web service, in every environment. Replaces railway.json +// (Config as Code), which Railway stops reading on 2026-12-01. +// +// Railway does not read this file on deploy. Preview and apply it per +// environment, from the repo root, with the environment linked: +// railway environment development # or uat, production +// railway config plan +// railway config apply +// +// "web" is a partial: this file owns only the web service. Postgres, its +// volume, and slack-cards (production) are managed elsewhere and are never +// touched by apply. +export const partial = "web"; + +// The Node version comes from .nvmrc, the one place the repo names it +// (test/toolchain.test.ts keeps package.json engines in step). Railpack reads +// RAILPACK_NODE_VERSION before engines, so this variable is what decides the +// Node a deploy runs. COACHATRON_IAC_PRESERVE_NODE=1 keeps the live value +// instead, for applying the rest of this file without changing Node. +function nodeVersion() { + if (process.env.COACHATRON_IAC_PRESERVE_NODE === "1") return preserve(); + return readFileSync(new URL("../.nvmrc", import.meta.url), "utf8").trim(); +} + +const ENVIRONMENTS = { + development: { branch: "develop", domains: ["dev.coachatron.com"] }, + uat: { branch: "staging", domains: ["uat.coachatron.com"] }, + production: { branch: "main", domains: ["coachatron.com", "www.coachatron.com"] }, +} as const; + +export default defineRailway((ctx) => { + const name = (Object.keys(ENVIRONMENTS) as Array).find((env) => ctx.isEnvironment(env)); + if (!name) throw new Error(`No web settings for Railway environment "${ctx.environmentName}"`); + const env = ENVIRONMENTS[name]; + + const web = service("web", { + source: github("YOLOVibeCode/coachatron", { branch: env.branch, checkSuites: false }), + build: "npm run build", + start: "npm start", + healthcheck: "/", + healthcheckTimeout: 30, + replicas: { "us-east4-eqdc4a": 1 }, + domains: env.domains.map((domain) => ({ domain, port: 3000 })), + // Values live in Railway, never in this file. preserve() keeps each one. + env: { + APP_BASE_URL: preserve(), + APP_ENV: preserve(), + DATABASE_URL: preserve(), + LITELLM_API_KEY: preserve(), + LITELLM_BASE: preserve(), + NODE_ENV: preserve(), + PORT: preserve(), + RAILPACK_NODE_VERSION: nodeVersion(), + RELAY_API_KEY: preserve(), + RELAY_BASE_URL: preserve(), + RELAY_CONNECT_PRODUCT: preserve(), + RELAY_WEBHOOK_SECRET: preserve(), + SESSION_SECRET: preserve(), + STORE_BASE_URL: preserve(), + }, + }); + + return project("coachatron", { resources: [web] }); +}); diff --git a/AGENTS.md b/AGENTS.md index 9a8a18f..1a5de6f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,7 +11,7 @@ files win over a generic stack preference. - One service. Postgres in production. Tests may use PGlite so `npm test` needs no daemon. - Payments go through signed Noctusoft marketplace buy links (`GET /buy/connect/coachatron/:seller`). Do not import a Square SDK or card form. - SMS and email go through the Noctusoft relay. Do not import Twilio or SendGrid. -- The model is one interface. The production client calls LiteLLM on litellm-vm at `https://ai.noctusoft.com/v1`. No provider SDK. Tests use a fake. The text assistant (SMS + the schedule-screen field) is the only caller. +- The model is one interface. The production client calls LiteLLM on litellm-vm at `https://ai.noctusoft.com/v1`. No provider SDK. Tests use a fake. The text assistant and voice setup (SMS + the schedule screen) are the only callers. ## Commands @@ -23,6 +23,7 @@ Fill these in as the project gains them, and keep them true: | Test | `npm test` | | Typecheck | `npm run typecheck` | | Dev | `npm run dev` | +| Eval voice setup (calls the live model; run on purpose only) | `LITELLM_API_KEY=... npm run eval:setup` | ## Never diff --git a/README.md b/README.md index 5b61ce0..554427e 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,9 @@ Later, the coach runs all of it by texting the number in plain English. ## Prerequisites -- Node.js ≥ 20.11 (see `engines` in `package.json`). That's the whole list. +- Node.js 24 (the current LTS). `.nvmrc` names it, so `nvm use` (or a shell + that switches on `.nvmrc`) picks it up; `npm ci` refuses an older Node + (`engine-strict` in `.npmrc`). That's the whole list. - No Postgres, no Docker, no database to install — tests run against [PGlite](https://pglite.dev) (Postgres compiled to WASM, in-memory). - No accounts to create and no secrets required to run locally. Payments @@ -91,9 +93,9 @@ The twelve screens of Coachatron (Slice 1): | # | Screen | Route(s) | Who | |---|--------|----------|-----| | 1 | Sign in | `/signin`, `/signin/otp`, `/signin/verify` | Coach | -| 2 | Schedule | `/app/schedule` | Coach | +| 2 | Schedule (and voice setup) | `/app/schedule`, `/app/setup`, `/app/setup/publish`, `/app/setup/discard` | Coach | | 3 | Session detail | `/app/sessions/:id`, `/app/sessions/:id/bookings/:bookingId/attendance`, `/app/sessions/:id/cancel` | Coach | -| 4 | Session types | `/app/session-types`, `/app/session-types/:id/generate-week` | Coach | +| 4 | Session types | `/app/session-types`, `/app/session-types/:id/generate-week` (weekly times), `/app/session-types/:id/slots/:slotId/remove` | Coach | | 5 | Pricing | `/app/pricing`, `/app/pricing/package`, `/app/pricing/plan` | Coach | | 6 | Roster | `/app/roster`, `/app/roster/:id/priority` | Coach | | 7 | Money (and connecting payments) | `/app/money`, `/app/money/payments` | Coach | @@ -137,6 +139,35 @@ Do not commit secrets. `npm test` uses the in-process fake in `test/fakes/relay. coach is recorded on the same session via `assigned_roster_member_id`. See `ROADMAP.md` M4 for why. +## Texting ceilings + +Every outbound text goes through `sendText()` in `src/domain/outbound.ts` +(a test fails if anything calls the relay directly). Before it sends, it: +fits the text into one segment (flattening curly quotes, dashes, and the +narrow space `Intl` puts before "PM", so a text never silently becomes +UCS-2); refuses numbers outside `+1` (US and Canada, the destinations priced +near $0.0083 a segment); and stops at 300 texts per coach per local day, +2,000 per coach per month, 400 per active coach per day for the product +(never below 500), and 5 sign-in codes per number per day. Every attempt, +sent or stopped, is written to `message_log`. + +## Voice-setup eval (live model, opt-in) + +`npm test` only ever uses a fake model. To check extraction against the +real one on litellm-vm, run `LITELLM_API_KEY=... npm run eval:setup`. It +runs the production prompt, schema, and validation over +`eval/setup-utterances.json` and prints PASS/MISS per case. The starter +cases are synthetic; add the launch coach's real descriptions before +trusting the number. + +## Background job + +The running server ticks once a minute (`src/jobs/tick.ts`). Each tick keeps +every weekly time 8 weeks ahead on the calendar, passes an expired overflow +offer to the next backup coach, and sends 24-hour reminders outside quiet +hours (9pm–8am in the coach's timezone). Tests call `runTick()` directly +with a fixed clock; `createApp()` never starts the timer. + None of these affect the three journeys in the product brief (booking + payment, the overflow cascade, the read-only Money screen), which are all covered by `test/journeys.test.ts`. diff --git a/ROADMAP.md b/ROADMAP.md index f1c308a..484800c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -31,7 +31,7 @@ running database, Square, Twilio, SendGrid, or a model provider. ## Stack (decided once, in M1, not reopened) -- **Language:** TypeScript 5, Node.js ≥ 20.11, ESM (`"type": "module"`). +- **Language:** TypeScript 5, Node.js ≥ 20.11, ESM (`"type": "module"`). *(Now TypeScript 6 and Node 24; see M11.)* - **Web:** Express 4. Server-rendered HTML via hand-written template functions (tagged-template `html` helper with escaping) — **no templating engine dependency, no SPA framework.** @@ -1037,3 +1037,192 @@ but a real gap between `SPEC.md`'s wording and Slice 1 as shipped. Recorded as `SPEC.md §15` item 7 rather than either quietly building it (scope creep at the very last step, with no milestone-level test design behind it) or quietly ignoring the discrepancy. + +--- + +## M8: Take and run +Status: [x] done +Goal: A coach who sets up once never has to come back to keep the calendar +full, and the background work SPEC.md already promises actually runs. +Acceptance: +- [x] Weekly times are saved (`weekly_slot`) and fill 8 weeks ahead; the + same local time holds across a DST change; regenerating never + duplicates a session (`test/weekly-slots.test.ts`) +- [x] Removing a weekly time cancels its empty future sessions and keeps + booked ones for the coach to cancel one by one +- [x] A one-minute tick (`src/jobs/tick.ts`) tops up weekly times, advances + expired overflow offers, and sends one 24-hour reminder per booking + outside quiet hours, skipping opted-out numbers (`test/tick.test.ts`) +- [x] Sign-up asks only for a name: the timezone comes from the browser + and falls back to America/Chicago, and email is optional +- [x] Landing page has a Start link; schedule cards link to session detail + and show booked/capacity; every coach screen has the same plain nav +- [x] Overflow texts name the session; the offer text and page show the + headcount and the backup coach's pay (`session_type.backup_pay_cents`) +- [x] All five quality-bar commands still exit 0 + +Notes: +- `generateWeekSessions` (7 days, once) is replaced by `createWeeklySlots` + and `ensureSlotSessions`. The route path `generate-week` is kept so links + and tests stay stable; the screen is now "Weekly times". +- The tick starts only in the `server.ts` boot block, never in + `createApp()`, so tests stay deterministic. Railway runs one replica; more + would need `pg_try_advisory_lock` around `runTick`. +- No thirteenth screen: slot removal is a POST on screen 4. + +--- + +## M9: Talk your week in +Status: [x] done +Goal: A new coach reaches a live, bookable week in three decisions: Start, +"Set it up" (after saying their week), Publish. +Acceptance: +- [x] A coach with nothing on the calendar sees "Tell me your week" on the + schedule screen; the model fills `SETUP_JSON_SCHEMA` and nothing else +- [x] `validateSetupPlan` drops bad days and times, reuses types the coach + already has, enforces limits, and turns a missing price or headcount + into a question rather than a guess (`test/setup.test.ts`) +- [x] The preview is rendered by product code; a draft writes nothing to + the schedule; Publish is blocked while a question is open and runs once +- [x] A follow-up message ("make Thursday 6:30") updates the same draft, + with the current setup and open questions sent to the model +- [x] A model failure or a spent budget falls back to the by-hand forms +- [x] By SMS, a coach with no schedule gets a draft and a one-segment + reply with the link; a scheduled coach's texts are unchanged +- [x] `test/journeys.test.ts` journey 4 walks Start → say it → Publish → + the public page lists the sessions +- [x] All five quality-bar commands still exit 0 + +Notes: +- `PLATFORM.md` §4.3 R5 gains an additive-setup exception, approved by the + owner; §4.2 documents the second schema. `SPEC.md` §7.1 step 3 now leads + with talking the week in; the forms remain as the fallback. +- The model budget moved to `src/domain/modelBudget.ts` so setup and the + assistant share one per-coach cap. Setup calls use an 8s timeout (a web + form with a larger answer); the SMS assistant keeps 3s. +- Publish is idempotent instead of transactional (the pg Pool does not pin a + client): the draft is claimed first, and existing types, weekly times, + packages, and plans are reused. +- No thirteenth screen: setup is screen 2's empty state and three POSTs. + +--- + +## M10: Texting ceilings and the voice-setup eval +Status: [x] done +Goal: Close the two gaps left after M8 and M9: nothing stopped a texting +loop, and voice setup had never met the real model. +Acceptance: +- [x] One send path (`sendText`, `src/domain/outbound.ts`); a test fails if + anything in `src/` calls the relay's SMS endpoint directly +- [x] Every text is one segment: GSM-7 up to 160, UCS-2 up to 70, with + typographic characters flattened first +- [x] Refuses non-`+1` destinations; stops at 300/coach/day, 2,000/coach/ + month, 400 × active coaches/product/day (floor 500), and 5 sign-in + codes per number per day; every attempt is in `message_log` +- [x] `npm run eval:setup` runs production extraction against the live + model over `eval/setup-utterances.json`; refuses to run without + `LITELLM_API_KEY`; not part of `npm test` +- [x] All five quality-bar commands still exit 0 + +Notes: +- Fixed the intermittent full-suite failures: a request that hung for 300s, + a `fetch failed`, or a stray 401, in a different file each time. Root + cause: test servers called a bare `listen(0)`, which binds every + interface, while tests connect to `127.0.0.1`. macOS will give such a + listener a port number another program already holds on `127.0.0.1` (this + machine has 34, including editors and VPN tools), and the request then + reaches that program. Proven directly: a wildcard listener was allowed on a + port Cursor held on 127.0.0.1, and a fetch to it hung; an explicit + 127.0.0.1 bind of that port is refused with EADDRINUSE. Every test server + now binds through `test/helpers/listen.ts` (`listenLoopback`), which binds + 127.0.0.1 and waits for the port. Test servers also answer + `Connection: close` so fetch never pools sockets across tests. +- The eval set is synthetic. Its pass rate means little until the launch + coach's own descriptions are in it. + +--- + +## M11: Current toolchain, one Node version everywhere +Status: [x] done +Goal: Run on the current Node LTS and current dependencies, with the Node +version named once and every environment reading it. +Acceptance: +- [x] `.nvmrc` is `24`; `package.json` `engines.node` is `>=24`; + `test/toolchain.test.ts` fails if they name different lines, if the + line is odd (non-LTS), or if CI stops reading `.nvmrc` +- [x] Production's Node is known, not assumed: Node 22.23.2 in all three + Railway environments (read from the running containers), pinned by a + `RAILPACK_NODE_VERSION=22` variable that Railpack reads before + `engines`. It stays on 22 until that variable changes; M12 makes + `.railway/railway.ts` set it from `.nvmrc` +- [x] CI's required `CI` check runs on `.nvmrc`; a non-blocking + `Next Node` job runs `lts/*` and `latest`, so the next LTS is tested + before anyone moves the pin +- [x] `npm ci` on an older Node fails fast (`engine-strict`) +- [x] Express 5, PGlite 0.5, TypeScript 6.0, ESLint 10, `@types/node` 24, + and current minors, with no source changes beyond the boot log; + compiler target ES2024 +- [x] All five quality-bar commands exit 0 on Node 24 + +Notes: +- TypeScript 7 is out but `typescript-eslint` supports below 6.1, so 6.0.3 + is the newest usable. Revisit when `typescript-eslint` widens its range. +- npm 12 (with Node 24) blocks install scripts unless allowed; + `package.json` `allowScripts` permits esbuild's and fsevents', which npm + 10 ran anyway. Older npm ignores the field. +- Express 5 sends a rejected async handler to the error handler. Under + Express 4 such a request never got a response. +- The server logs its Node version at boot, so `railway logs` shows what a + deploy actually runs. +- To move to the next LTS: change `.nvmrc` and `engines` together (the + toolchain test enforces it), after the `Next Node (lts/*)` job is green. + +--- + +## M12: Railway Infrastructure as Code +Status: [x] done +Goal: Retire `railway.json` (Config as Code, which Railway stops reading on +2026-12-01) without changing anything that runs, and let `.nvmrc` decide +production's Node. +Acceptance: +- [x] `.railway/railway.ts` describes the `web` service in development + (`develop`), uat (`staging`) and production (`main`): source, build, + start, healthcheck, replicas, custom domains, and every variable as + `preserve()` (values never in git) +- [x] It is a `web` partial: Postgres, its volume, and slack-cards are not + declared and apply cannot touch them +- [x] `railway config plan` in all three environments: "already up to + date" (with the Node line preserved), so the file matches what runs; + applied to all three, which recorded ownership and changed nothing +- [x] `railway config migrate --apply --service web` run in all three: + no Config File setting was set, so nothing to clear +- [x] `railway.json` deleted; `test/toolchain.test.ts` fails if it returns, + if the file stops reading `.nvmrc`, or if it declares Postgres or + slack-cards +- [x] All five quality-bar commands still exit 0 + +Notes: +- The first `migrate` output was not safe to apply: it named the service + `coachatron` (the live one is `web`), and with only build/start/healthcheck + declared, `plan` showed it would delete all 14 web variables and detach the + GitHub source. The file was rebuilt from `railway config pull` in each + environment instead. +- `RAILPACK_NODE_VERSION` comes from `.nvmrc`, so moving Node is a one-file + change. Applying that is the only pending change in each environment: + `plan` shows exactly one update, `web.RAILPACK_NODE_VERSION` (22 to 24). + It changes the runtime of a live service, so it is applied per + environment on purpose (development, then uat, then production), not as + part of this migration. `COACHATRON_IAC_PRESERVE_NODE=1` applies the rest + of the file without it. +- Applied 2026-10-01 with the owner's go-ahead, in order. development: + `config apply`, rebuilt `8cb1fb3`. uat: `config apply`, rebuilt `9f42394`. + production: `config apply` refused (a Railway bug that rejects the two + existing custom domains on any change), so the same single change was made + with `railway variable set`, rebuilt `97012f4`. All three now run Node + 24.21.0 (read with `railway ssh`); `/` and `/signin` return 200 and a + database-backed 404 route answers on every domain; `config plan` is + "already up to date" in all three. No code changed in any deploy. +- The `railway` SDK checks the CLI version by running `$_`, the last command + the shell ran. Wrapping `railway config plan` in `timeout` (or anything + else) makes it fail with a misleading "requires Railway CLI 5.42.1". + diff --git a/SPEC.md b/SPEC.md index d7cb5aa..a9735a8 100644 --- a/SPEC.md +++ b/SPEC.md @@ -135,12 +135,18 @@ 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. Creates first session type in one form: *what it's called, how long, how many - athletes, how much*. Every other field is defaulted. +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. + 8 weeks ahead, rolling. *(Implemented: each weekly time is a `weekly_slot`, + topped up by the one-minute tick job.)* 5. Connects payments (Square OAuth, one redirect). **Skippable** — the coach can share a booking link immediately and connect before the first payout. 6. Gets their link: `coachatron.com/c/`. Done. diff --git a/docs/PLATFORM.md b/docs/PLATFORM.md index 6b5aa7b..2ca5848 100644 --- a/docs/PLATFORM.md +++ b/docs/PLATFORM.md @@ -189,6 +189,14 @@ anything. It classifies and extracts. | `roster.offer` | write | **yes** | | `unknown` | — | replies with a link | +**Second closed schema: setup.** `SETUP_JSON_SCHEMA` (`src/llm/setupSchema.ts`) +extracts a coach's described week: session types, weekly times, packages, +plans, and an optional time zone. The model only fills it. Product code +validates every value, never guesses a price or a headcount (it asks), renders +the preview, and writes on Publish (`src/domain/setup.ts`). It runs on the +schedule screen, and by SMS for a coach with nothing on the calendar yet. It +shares the per-coach model caps. + ### 4.3 Safety rules These are the whole design. Without them this feature is a liability. @@ -204,10 +212,16 @@ 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. + **Exception (2026-09-30): additive setup.** Voice setup (session types, weekly + times, packages, plans) may create many rows from one message. It cancels + nothing, moves no money, texts no athletes, and is written only when the + coach taps Publish on a preview the product rendered from the extracted + plan (R2). Bulk cancel or delete stays on the web. - **R6 — Pending confirmations expire** in 10 minutes and are single-use, so a stale `Y` cannot fire a forgotten action. - **R7 — Everything is logged** — raw message, parsed intent, confirmation, and @@ -266,6 +280,12 @@ prices like an international destination. Those are capped in the send path | Product model key | $20/month | raise on purpose, not by usage | | Product, per day | 400 segments × active coaches, floor 500 | a shared-sender bug dies the same day | +*Implemented 2026-09-30* in `src/domain/outbound.ts`, the only send path. +Destination price is enforced by country code: only `+1` (US and Canada) is +texted, since the relay does not report a per-destination price. "Active +coach" means a coach with a scheduled session ahead. Sign-in codes are also +capped at 5 per number per day. + The full assistant fits inside those ceilings. Scheduling by text, setting a session up, asking before a change, confirmations, reminders, and the overflow cascade for a busy coach land around 600–1,200 segments and well under 400 diff --git a/eval/setup-utterances.json b/eval/setup-utterances.json new file mode 100644 index 0000000..61af5f7 --- /dev/null +++ b/eval/setup-utterances.json @@ -0,0 +1,130 @@ +{ + "note": "Synthetic starter set, written to sound like phone dictation. Replace or extend with the launch coach's real descriptions before trusting the pass rate. weekday: 0 Sun .. 6 Sat; time_local is 24h.", + "cases": [ + { + "id": "keeper-basic", + "text": "Keeper group Tuesdays and Thursdays at 6 at field 3, an hour, 8 kids, $35", + "expect": { + "types": [{ "name_includes": "keeper", "duration_min": 60, "capacity": 8, "price_cents": 3500 }], + "weekly": [ + { "type_name_includes": "keeper", "weekday": 2, "time_local": "18:00" }, + { "type_name_includes": "keeper", "weekday": 4, "time_local": "18:00" } + ] + } + }, + { + "id": "two-types-and-pack", + "text": "I run a goalkeeper group Monday and Wednesday at 5:30 for an hour, up to 10 kids, 30 bucks. Privates Saturday morning at 9 10 and 11, 45 minutes, $70. And a 10 pack for 300.", + "expect": { + "types": [ + { "name_includes": "group", "duration_min": 60, "capacity": 10, "price_cents": 3000 }, + { "name_includes": "private", "duration_min": 45, "price_cents": 7000 } + ], + "weekly": [ + { "type_name_includes": "group", "weekday": 1, "time_local": "17:30" }, + { "type_name_includes": "group", "weekday": 3, "time_local": "17:30" }, + { "type_name_includes": "private", "weekday": 6, "time_local": "09:00" }, + { "type_name_includes": "private", "weekday": 6, "time_local": "10:00" }, + { "type_name_includes": "private", "weekday": 6, "time_local": "11:00" } + ], + "packages": [{ "credits": 10, "price_cents": 30000 }] + } + }, + { + "id": "backup-pay", + "text": "Keeper clinic Friday at 7, 90 minutes, 12 keepers, $40 each. If I need a backup coach they get 80 bucks.", + "expect": { + "types": [{ "name_includes": "clinic", "duration_min": 90, "capacity": 12, "price_cents": 4000, "backup_pay_cents": 8000 }], + "weekly": [{ "type_name_includes": "clinic", "weekday": 5, "time_local": "19:00" }] + } + }, + { + "id": "missing-price-asks", + "text": "Group training Tuesday and Thursday at six, an hour, eight kids", + "expect": { + "types": [{ "name_includes": "group", "capacity": 8, "price_cents": null }], + "weekly": [ + { "type_name_includes": "group", "weekday": 2, "time_local": "18:00" }, + { "type_name_includes": "group", "weekday": 4, "time_local": "18:00" } + ], + "questions_include": ["What do you charge"] + } + }, + { + "id": "morning-and-noon", + "text": "Sunday morning keeper session at 8am, 1 hour, 6 players, $25. Also Sunday at noon same thing.", + "expect": { + "weekly": [ + { "type_name_includes": "keeper", "weekday": 0, "time_local": "08:00" }, + { "type_name_includes": "keeper", "weekday": 0, "time_local": "12:00" } + ] + } + }, + { + "id": "half-hours-and-words", + "text": "Pitching lessons Wednesday six thirty, half an hour, one kid, sixty dollars", + "expect": { + "types": [{ "name_includes": "pitching", "duration_min": 30, "capacity": 1, "price_cents": 6000 }], + "weekly": [{ "type_name_includes": "pitching", "weekday": 3, "time_local": "18:30" }] + } + }, + { + "id": "dictation-typo", + "text": "Keypad group Monday at 4 at Riverside park an hour and a half 10 kids $45", + "expect": { + "types": [{ "duration_min": 90, "capacity": 10, "price_cents": 4500 }], + "weekly": [{ "weekday": 1, "time_local": "16:00" }] + } + }, + { + "id": "monthly-plan", + "text": "Swim lessons Tuesday at 4, 30 minutes, 4 kids, $25. Monthly plan is $90 for 4 lessons.", + "expect": { + "types": [{ "name_includes": "swim", "duration_min": 30, "capacity": 4, "price_cents": 2500 }], + "weekly": [{ "type_name_includes": "swim", "weekday": 2, "time_local": "16:00" }], + "plans": [{ "price_cents": 9000, "credits_per_month": 4 }] + } + }, + { + "id": "every-weekday", + "text": "After school keeper training every weekday at 3:45, an hour, 8 kids, $20", + "expect": { + "weekly": [ + { "weekday": 1, "time_local": "15:45" }, + { "weekday": 2, "time_local": "15:45" }, + { "weekday": 3, "time_local": "15:45" }, + { "weekday": 4, "time_local": "15:45" }, + { "weekday": 5, "time_local": "15:45" } + ] + } + }, + { + "id": "timezone-city", + "text": "I'm in Denver. Keeper group Thursday at 6, an hour, 8 kids, $35", + "expect": { + "coach_timezone": "America/Denver", + "weekly": [{ "weekday": 4, "time_local": "18:00" }] + } + }, + { + "id": "no-phone-numbers", + "text": "Keeper group Tuesday at 6, an hour, 8 kids, $35. My backup is Chris at 555-201-3344.", + "expect": { + "types": [{ "name_includes": "keeper", "price_cents": 3500 }], + "forbid_in_output": ["555-201-3344", "5552013344"] + } + }, + { + "id": "refine-thursday", + "draft_text": "Keeper group Tuesdays and Thursdays at 6, an hour, 8 kids, $35", + "text": "actually make Thursday 6:30", + "expect": { + "weekly": [ + { "weekday": 2, "time_local": "18:00" }, + { "weekday": 4, "time_local": "18:30" } + ], + "forbid_weekly": [{ "weekday": 4, "time_local": "18:00" }] + } + } + ] +} diff --git a/package-lock.json b/package-lock.json index 1369c95..4aec789 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,27 +9,52 @@ "version": "0.1.0", "dependencies": { "@noctusoft/store-client": "file:packages/store-client", - "express": "^4.19.2", - "pg": "^8.12.0" + "express": "^5.2.1", + "pg": "^8.23.1" }, "devDependencies": { - "@electric-sql/pglite": "^0.2.12", - "@types/express": "^4.17.21", - "@types/node": "^22.5.0", + "@electric-sql/pglite": "^0.5.8", + "@types/express": "^5.0.6", + "@types/node": "^24.19.0", "@types/pg": "^8.11.6", - "eslint": "^9.9.0", - "tsx": "^4.19.0", - "typescript": "^5.5.4", - "typescript-eslint": "^8.3.0" + "eslint": "^10.11.0", + "railway": "^3.12.0", + "tsx": "^4.23.15", + "typescript": "^6.0.3", + "typescript-eslint": "^8.71.0" }, "engines": { - "node": ">=20.11" + "node": ">=24" + } + }, + "node_modules/@cacheable/memory": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz", + "integrity": "sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/utils": "^2.5.0", + "@keyv/bigmap": "^1.3.1", + "hookified": "^1.15.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@cacheable/utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.5.0.tgz", + "integrity": "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.5.1", + "keyv": "^5.6.0" } }, "node_modules/@electric-sql/pglite": { - "version": "0.2.17", - "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.2.17.tgz", - "integrity": "sha512-qEpKRT2oUaWDH6tjRxLHjdzMqRUGYDnGZlKrnL4dJ77JVMcP2Hpo3NYnOSPKdZdeec57B6QPprCUFg0picx5Pw==", + "version": "0.5.8", + "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.8.tgz", + "integrity": "sha512-n9tsbUOhwx2epK1V0ZG9Ar4SHWUju04dhmzZXiSBXwBoleOvIfals33NAaWgagQVAL4Rbvx/Ptsu3P+pA09f6Q==", "dev": true, "license": "Apache-2.0" }, @@ -518,105 +543,78 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.21.2", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", - "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/object-schema": "^2.1.7", + "@eslint/object-schema": "^3.0.5", "debug": "^4.3.1", - "minimatch": "^3.1.5" + "minimatch": "^10.2.4" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/config-helpers": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", - "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.17.0" + "@eslint/core": "^1.2.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/core": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", - "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", "dev": true, "license": "Apache-2.0", "dependencies": { "@types/json-schema": "^7.0.15" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/eslintrc": { - "version": "3.3.7", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.7.tgz", - "integrity": "sha512-F42g89Qd5oAWtp0k0nnSrjziAKza7w8SVT4mStc18LZMaRb4J1HQAHLCalEtDCxrTuksx7NU9qsmeLwpOfPqWw==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^6.14.0", - "debug": "^4.3.2", - "espree": "^10.0.1", - "globals": "^14.0.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.3.2", - "minimatch": "^3.1.5", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint/js": { - "version": "9.39.5", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.5.tgz", - "integrity": "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://eslint.org/donate" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/object-schema": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", - "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", "dev": true, "license": "Apache-2.0", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/plugin-kit": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", - "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.3.tgz", + "integrity": "sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.17.0", + "@eslint/core": "^1.2.1", "levn": "^0.4.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@graphql-typed-document-node/core": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@graphql-typed-document-node/core/-/core-3.2.0.tgz", + "integrity": "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "node_modules/@humanfs/core": { @@ -685,6 +683,30 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@keyv/bigmap": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz", + "integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.4.0", + "hookified": "^1.15.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "keyv": "^5.6.0" + } + }, + "node_modules/@keyv/serialize": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", + "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", + "dev": true, + "license": "MIT" + }, "node_modules/@noctusoft/store-client": { "resolved": "packages/store-client", "link": true @@ -710,6 +732,13 @@ "@types/node": "*" } }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -718,22 +747,21 @@ "license": "MIT" }, "node_modules/@types/express": { - "version": "4.17.25", - "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", - "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", + "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", "dev": true, "license": "MIT", "dependencies": { "@types/body-parser": "*", - "@types/express-serve-static-core": "^4.17.33", - "@types/qs": "*", - "@types/serve-static": "^1" + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "^2" } }, "node_modules/@types/express-serve-static-core": { - "version": "4.19.9", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.9.tgz", - "integrity": "sha512-QP2ESEe/ImWY0HDwNAnK9PvEffUyhLTnWkk7KXzHfyeWAnlrDe1fN77bXl6ia8KT3wPlmA7t9/VPRpnf4Ex9sg==", + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.3.tgz", + "integrity": "sha512-dPfW8NFiOF4wOHc7+N/QSxlY9cfSsenewGbAz8C8U/MULPd/YZ27LvJUIlzaXie7e6Ove9YunJGgC9tbHD2cKw==", "dev": true, "license": "MIT", "dependencies": { @@ -757,21 +785,14 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/mime": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", - "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/node": { - "version": "22.20.4", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.4.tgz", - "integrity": "sha512-zJRE40jpHtKqE/C4fgHrAKQLJuSpzEnP9ff9Y7YtoR3Wd2pwqzlekDeEuUQXjRd+QCYnVnNwuJYmhdk9XV8gvA==", + "version": "24.19.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.0.tgz", + "integrity": "sha512-zY+5tKxXdhGh1PYI0ac+7juvEu4OI6vWtVVoj5i2m42jxAY1U+zHGt6QCyOFwykdP62sM3MJ9stoYYUw5aCWew==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~6.21.0" + "undici-types": ">=7.24.0 <7.24.7" } }, "node_modules/@types/pg": { @@ -811,40 +832,28 @@ } }, "node_modules/@types/serve-static": { - "version": "1.15.10", - "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", - "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", "dev": true, "license": "MIT", "dependencies": { "@types/http-errors": "*", - "@types/node": "*", - "@types/send": "<1" - } - }, - "node_modules/@types/serve-static/node_modules/@types/send": { - "version": "0.17.6", - "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", - "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/mime": "^1", "@types/node": "*" } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.70.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.70.1.tgz", - "integrity": "sha512-nDNrUQ/4ruSNYbu749TRY7cfrzPtoLHEXSNBI8aaNY32LlZCajixqRf3FqcKC4p5Cam4VOHYx/t+i5+nKXvrqA==", + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.71.0.tgz", + "integrity": "sha512-pqcS9c1HxZTHt7End4nXqd0s5lJrrFzrgCkKFJrsbUnaL6M3+6oBFZaslg6Gjsl3argl2DDRFROnXARaZ2e4Nw==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.70.1", - "@typescript-eslint/type-utils": "8.70.1", - "@typescript-eslint/utils": "8.70.1", - "@typescript-eslint/visitor-keys": "8.70.1", + "@typescript-eslint/scope-manager": "8.71.0", + "@typescript-eslint/type-utils": "8.71.0", + "@typescript-eslint/utils": "8.71.0", + "@typescript-eslint/visitor-keys": "8.71.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -857,15 +866,15 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.70.1", + "@typescript-eslint/parser": "^8.71.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { - "version": "7.0.9", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.9.tgz", - "integrity": "sha512-brTTsvFRt5C1gGHtPst/281UjPD5t9fBqbgoMPlVWy11ZLTPfu7HxK4ZYqO9H7o/yC9rSTCI85EaQ4OoY12qYw==", + "version": "7.0.11", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.11.tgz", + "integrity": "sha512-YChdK5txDjwGUvgR7oCJcLGkwi3LDh8Zx8tS7ndYciLEg/oOCL26VUBSUEtgS7EjiybcBqFxh5j0rAdDX7/bbg==", "dev": true, "license": "MIT", "engines": { @@ -873,16 +882,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.70.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.70.1.tgz", - "integrity": "sha512-nO974WLllwhSFWQXnMLj6nDGa8f0khKEz1JzpPJ1u7Vm/4X1X6ZHajpoknU4bb41vJyMB0HHVyS2GqdhWfIXZw==", + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.71.0.tgz", + "integrity": "sha512-CG4nPk1f2zc8yw4pALqHsFYH2hdo+h1T9daSp21+Hnxi9LOE3GT9hAfTKJCBXVNM2GmYs1eMEP615wPoeOgk3A==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.70.1", - "@typescript-eslint/types": "8.70.1", - "@typescript-eslint/typescript-estree": "8.70.1", - "@typescript-eslint/visitor-keys": "8.70.1", + "@typescript-eslint/scope-manager": "8.71.0", + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/typescript-estree": "8.71.0", + "@typescript-eslint/visitor-keys": "8.71.0", "debug": "^4.4.3" }, "engines": { @@ -898,14 +907,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.70.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.70.1.tgz", - "integrity": "sha512-62xOgboPfwc3/IgPSX/W6oQR3ZbF04194FPGUGH8HL8iLFHbt/456/8Ph1wLNUgVF+s94FlHoipBsz+v7+LMnA==", + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.71.0.tgz", + "integrity": "sha512-aABjw5rjBacYONVPaPiWOCjJu0vEF4a25iQuodlmQYL1trtLZ0X/y+2Vzl3BKI1odM4LnwLE1oUDXYp1wzx1TQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.70.1", - "@typescript-eslint/types": "^8.70.1", + "@typescript-eslint/tsconfig-utils": "^8.71.0", + "@typescript-eslint/types": "^8.71.0", "debug": "^4.4.3" }, "engines": { @@ -920,14 +929,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.70.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.70.1.tgz", - "integrity": "sha512-Pa0EeSeAusQc1WbjQMac+YfenewYTBu0KjgYvkUKwhXaHUKbFog23Dm/rp0DX/6tyYOQ3Xl1a+3EcFNZynGHCw==", + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.71.0.tgz", + "integrity": "sha512-gWF0BhUcnjZxSpLE8ngS/59n2SB0J3YqRxvX1+2aoRJk9hNtHSLOV+TcarFiOr5ipXm3yc1QrI4c9YZc8zyCxw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.70.1", - "@typescript-eslint/visitor-keys": "8.70.1" + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/visitor-keys": "8.71.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -938,9 +947,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.70.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.70.1.tgz", - "integrity": "sha512-jumze1fPI+sDOaM2TWGQdn39PDxTr7TZGeuyLkAbNyx2vtMT3uRnVKChN0hfht5V2TugphJzF6bYXvBcE09qqg==", + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.71.0.tgz", + "integrity": "sha512-Z1UlWHADEK2Mlb9NpWfDeSjqoZ5EyrOv4R3eQpbkzqn/EwaIdOpXXupEA1+0ZIOSJSZZDBHG0BrQyN8zUG6Pwg==", "dev": true, "license": "MIT", "engines": { @@ -955,15 +964,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.70.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.70.1.tgz", - "integrity": "sha512-7zKTnyvaVWqzLZHPFQtX1hVHqgkMC+WebPWakNCSyrQVbIP1AM0L0TlBZtACldIRb6PptI8Odk+jyZ5kP3B1VA==", + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.71.0.tgz", + "integrity": "sha512-i8uO1qbdxeKgRnS5sCRt6On3/nfo2d2DwQe3Yvjx543zLy7r8ySqRuPPiIIXAhS03U0v5NfAFx+rUgxFzKKwNw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.70.1", - "@typescript-eslint/typescript-estree": "8.70.1", - "@typescript-eslint/utils": "8.70.1", + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/typescript-estree": "8.71.0", + "@typescript-eslint/utils": "8.71.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -980,9 +989,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.70.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.70.1.tgz", - "integrity": "sha512-Dm1ypdhhrGCTyyehxElhgJ6kgk8MVCv5qXdoOVqPr1uqk42jX8KjrZqhROvdShczA8qrDoYiOWn1ykWlx2k81Q==", + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.71.0.tgz", + "integrity": "sha512-cJ4OoxPGWvFnBTnSZyaU+qJzGTqPTGJY+gDchj6cRyLRdmIdt4rcsE4twj+zPfrNiWuVi38wijHzShL++Z9atQ==", "dev": true, "license": "MIT", "engines": { @@ -994,16 +1003,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.70.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.70.1.tgz", - "integrity": "sha512-TU8PwyGN0PQJUcE96mw8eCQ44SmxGdQlJmlWakHaHQ15eIuuvye5yNtmh/i6oS88jzXVQB71xdNkbkB/fMwL0g==", + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.71.0.tgz", + "integrity": "sha512-PEEF4G5sLLWAS5BpPrUvms4ySZkiBQQZM4z+3ReI46axK5Vqr/vXBQatJQIZZOYdGyPUAKTtsrWzpqKuU+3DEw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.70.1", - "@typescript-eslint/tsconfig-utils": "8.70.1", - "@typescript-eslint/types": "8.70.1", - "@typescript-eslint/visitor-keys": "8.70.1", + "@typescript-eslint/project-service": "8.71.0", + "@typescript-eslint/tsconfig-utils": "8.71.0", + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/visitor-keys": "8.71.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -1021,56 +1030,17 @@ "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "5.0.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", - "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "10.2.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", - "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.8" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/@typescript-eslint/utils": { - "version": "8.70.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.70.1.tgz", - "integrity": "sha512-Esgul8MsnKnRLdYU2Eb2cRV9bS5HJYtKj1ByJnOzzG2M58DGdSUQ1jUuILxipqcpB2h9WLrbD5GijIWUjX/Tqw==", + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.71.0.tgz", + "integrity": "sha512-pKR/tEMVrXZG23UFKUn5BQf3zfmfk7KQceI2cGzywZ5nxM5Eu3hEJU1utjWzydtzBbcJAQhHN8iPCxobHpPcZQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.70.1", - "@typescript-eslint/types": "8.70.1", - "@typescript-eslint/typescript-estree": "8.70.1" + "@typescript-eslint/scope-manager": "8.71.0", + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/typescript-estree": "8.71.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1085,13 +1055,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.70.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.70.1.tgz", - "integrity": "sha512-Vwj9lUIW5Xq3wQ9w6gv3R86g1hMK8f2zNOdGTAgeXUMMXFK78G9ruCjjqutHMNJc0+CH7LYRnHeUB9IT8wFmcw==", + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.71.0.tgz", + "integrity": "sha512-8eQ9R218XORK+KLosnf4bu/QsUXvUyVwTbArg7/0NMB1Pu87OJKvj4nhFblkYE8gQV73mW1dx1ptlPCkwRGa7A==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.70.1", + "@typescript-eslint/types": "8.71.0", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -1102,27 +1072,14 @@ "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", - "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, "node_modules/accepts": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", - "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", "license": "MIT", "dependencies": { - "mime-types": "~2.1.34", - "negotiator": "0.6.3" + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" }, "engines": { "node": ">= 0.6" @@ -1168,90 +1125,64 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", "dev": true, "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "node": "18 || 20 || >=22" } }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" - }, - "node_modules/array-flatten": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", - "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", - "license": "MIT" - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, "node_modules/body-parser": { - "version": "1.20.8", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.8.tgz", - "integrity": "sha512-JNcyFQ64OiijEkPzUBTCe+hyPXUD/3LEldGQ6iF5LR1w00mx9o7xtDWHXBY2iItjdCFGoilOLNQbH943ut7pHA==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", "license": "MIT", "dependencies": { - "bytes": "~3.1.2", - "content-type": "~1.0.5", - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "~1.2.0", - "http-errors": "~2.0.1", - "iconv-lite": "~0.4.24", - "on-finished": "~2.4.1", - "qs": "~6.16.0", - "raw-body": "~2.5.3", - "type-is": "~1.6.18", - "unpipe": "~1.0.0" + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" }, "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/body-parser/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "node_modules/body-parser/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", "license": "MIT", - "dependencies": { - "ms": "2.0.0" + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/body-parser/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, "node_modules/brace-expansion": { - "version": "1.1.21", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", - "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" } }, "node_modules/bytes": { @@ -1263,6 +1194,20 @@ "node": ">= 0.8" } }, + "node_modules/cacheable": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.5.0.tgz", + "integrity": "sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/memory": "^2.2.0", + "@cacheable/utils": "^2.5.0", + "hookified": "^1.15.0", + "keyv": "^5.6.0", + "qified": "^0.10.1" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -1292,70 +1237,17 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, "engines": { - "node": ">=10" + "node": ">=18" }, "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, - "license": "MIT" - }, - "node_modules/content-disposition": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", - "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", - "license": "MIT", - "dependencies": { - "safe-buffer": "5.2.1" - }, - "engines": { - "node": ">= 0.6" + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/content-type": { @@ -1377,10 +1269,13 @@ } }, "node_modules/cookie-signature": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", - "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", - "license": "MIT" + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } }, "node_modules/cross-spawn": { "version": "7.0.6", @@ -1401,7 +1296,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -1431,16 +1325,6 @@ "node": ">= 0.8" } }, - "node_modules/destroy": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", - "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", - "license": "MIT", - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -1562,45 +1446,43 @@ } }, "node_modules/eslint": { - "version": "9.39.5", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.5.tgz", - "integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==", - "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "version": "10.11.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.11.0.tgz", + "integrity": "sha512-P7a6UEEqb9G95MYAtqkmsTbVXIYyzIfl6NGOIJk162PaahFxFyeGcrlXYFSiagECg4sEm8IseJdZBKR3rx6MsQ==", "dev": true, "license": "MIT", + "workspaces": [ + "packages/*" + ], "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", - "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.21.2", - "@eslint/config-helpers": "^0.4.2", - "@eslint/core": "^0.17.0", - "@eslint/eslintrc": "^3.3.6", - "@eslint/js": "9.39.5", - "@eslint/plugin-kit": "^0.4.1", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.7.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.3", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", - "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.4.0", - "eslint-visitor-keys": "^4.2.1", - "espree": "^10.4.0", - "esquery": "^1.5.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^8.0.0", + "file-entry-cache": "11.1.5 || >11.1.6 <12", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.5", + "minimatch": "^10.2.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, @@ -1608,7 +1490,7 @@ "eslint": "bin/eslint.js" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://eslint.org/donate" @@ -1623,48 +1505,50 @@ } }, "node_modules/eslint-scope": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", - "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", "dev": true, "license": "BSD-2-Clause", "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" } }, "node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", "dev": true, "license": "Apache-2.0", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" } }, "node_modules/espree": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", - "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "acorn": "^8.15.0", + "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.1" + "eslint-visitor-keys": "^5.0.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" @@ -1726,66 +1610,48 @@ } }, "node_modules/express": { - "version": "4.22.3", - "resolved": "https://registry.npmjs.org/express/-/express-4.22.3.tgz", - "integrity": "sha512-Bdcs4+3qlpVlx2NRn6fgX2Ue2/gGRaPeawebgclM0ERSCqDpA+owF1fdPwjJUTAJWMTuAaxjDf+hzb0/4eKvvw==", - "license": "MIT", - "dependencies": { - "accepts": "~1.3.8", - "array-flatten": "1.1.1", - "body-parser": "~1.20.5", - "content-disposition": "~0.5.4", - "content-type": "~1.0.4", - "cookie": "~0.7.1", - "cookie-signature": "~1.0.6", - "debug": "2.6.9", - "depd": "2.0.0", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "finalhandler": "~1.3.1", - "fresh": "~0.5.2", - "http-errors": "~2.0.0", - "merge-descriptors": "1.0.3", - "methods": "~1.1.2", - "on-finished": "~2.4.1", - "parseurl": "~1.3.3", - "path-to-regexp": "~0.1.13", - "proxy-addr": "~2.0.7", - "qs": "~6.16.0", - "range-parser": "~1.2.1", - "safe-buffer": "5.2.1", - "send": "~0.19.0", - "serve-static": "~1.16.2", - "setprototypeof": "1.2.0", - "statuses": "~2.0.1", - "type-is": "~1.6.18", - "utils-merge": "1.0.1", - "vary": "~1.1.2" - }, - "engines": { - "node": ">= 0.10.0" + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/express" } }, - "node_modules/express/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/express/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -1826,51 +1692,36 @@ } }, "node_modules/file-entry-cache": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", - "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "version": "11.1.5", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.5.tgz", + "integrity": "sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==", "dev": true, "license": "MIT", "dependencies": { - "flat-cache": "^4.0.0" - }, - "engines": { - "node": ">=16.0.0" + "flat-cache": "^6.1.23" } }, "node_modules/finalhandler": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", - "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", "license": "MIT", "dependencies": { - "debug": "2.6.9", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "on-finished": "~2.4.1", - "parseurl": "~1.3.3", - "statuses": "~2.0.2", - "unpipe": "~1.0.0" + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" }, "engines": { - "node": ">= 0.8" - } - }, - "node_modules/finalhandler/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", - "dependencies": { - "ms": "2.0.0" + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/finalhandler/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -1889,17 +1740,15 @@ } }, "node_modules/flat-cache": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", - "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "version": "6.1.23", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.23.tgz", + "integrity": "sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==", "dev": true, "license": "MIT", "dependencies": { - "flatted": "^3.2.9", - "keyv": "^4.5.4" - }, - "engines": { - "node": ">=16" + "cacheable": "^2.5.0", + "flatted": "^3.4.2", + "hookified": "^1.15.0" } }, "node_modules/flatted": { @@ -1919,12 +1768,12 @@ } }, "node_modules/fresh": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", - "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", "license": "MIT", "engines": { - "node": ">= 0.6" + "node": ">= 0.8" } }, "node_modules/fsevents": { @@ -2001,19 +1850,6 @@ "node": ">=10.13.0" } }, - "node_modules/globals": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -2026,14 +1862,14 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "node_modules/graphql": { + "version": "16.14.2", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.14.2.tgz", + "integrity": "sha512-Chq1s4CY7jmh8gO2qvLIJyfCDIN+EHLFW/9iShnp1z8FjBQMoodWP1kDC36VAMXXIvAjj4ARa7ntfAV2BrjsbA==", "dev": true, "license": "MIT", "engines": { - "node": ">=8" + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" } }, "node_modules/has-symbols": { @@ -2048,6 +1884,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/hashery": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz", + "integrity": "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^1.15.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/hasown": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", @@ -2060,6 +1909,13 @@ "node": ">= 0.4" } }, + "node_modules/hookified": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz", + "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==", + "dev": true, + "license": "MIT" + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -2081,15 +1937,19 @@ } }, "node_modules/iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", "license": "MIT", "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" + "safer-buffer": ">= 2.1.2 < 3.0.0" }, "engines": { "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/ignore": { @@ -2102,23 +1962,6 @@ "node": ">= 4" } }, - "node_modules/import-fresh": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -2167,6 +2010,12 @@ "node": ">=0.10.0" } }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -2174,36 +2023,6 @@ "dev": true, "license": "ISC" }, - "node_modules/js-yaml": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", - "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/puzrin" - }, - { - "type": "github", - "url": "https://github.com/sponsors/nodeca" - } - ], - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/json-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true, - "license": "MIT" - }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -2219,13 +2038,13 @@ "license": "MIT" }, "node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", "dev": true, "license": "MIT", "dependencies": { - "json-buffer": "3.0.1" + "@keyv/serialize": "^1.1.1" } }, "node_modules/levn": { @@ -2258,13 +2077,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true, - "license": "MIT" - }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -2275,76 +2087,69 @@ } }, "node_modules/media-typer": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", - "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", "license": "MIT", "engines": { - "node": ">= 0.6" - } - }, - "node_modules/merge-descriptors": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", - "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", - "license": "MIT", + "node": ">= 0.8" + }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/methods": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", - "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", "license": "MIT", "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", - "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", - "license": "MIT", - "bin": { - "mime": "cli.js" + "node": ">=18" }, - "engines": { - "node": ">=4" + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", "license": "MIT", "engines": { "node": ">= 0.6" } }, "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", "license": "MIT", "dependencies": { - "mime-db": "1.52.0" + "mime-db": "^1.54.0" }, "engines": { - "node": ">= 0.6" + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^1.1.7" + "brace-expansion": "^5.0.8" }, "engines": { - "node": "*" + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/ms": { @@ -2361,12 +2166,32 @@ "license": "MIT" }, "node_modules/negotiator": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", - "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", + "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==", "license": "MIT", + "dependencies": { + "content-type": "^2.1.0" + }, "engines": { - "node": ">= 0.6" + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/negotiator/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/object-inspect": { @@ -2393,6 +2218,15 @@ "node": ">= 0.8" } }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -2443,19 +2277,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "callsites": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -2486,20 +2307,24 @@ } }, "node_modules/path-to-regexp": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", - "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", - "license": "MIT" + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } }, "node_modules/pg": { - "version": "8.23.0", - "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz", - "integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==", + "version": "8.23.1", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.1.tgz", + "integrity": "sha512-aL96AHANtWjPLDOLqnhx+ngp9+UK7ETEU8VJrDCGvsSSi/mGLcWYsS6Herg7lmaBJe4uwrfqsa7gTEFaSizDoQ==", "license": "MIT", "dependencies": { - "pg-connection-string": "^2.14.0", + "pg-connection-string": "^2.14.1", "pg-pool": "^3.14.0", - "pg-protocol": "^1.16.0", + "pg-protocol": "^1.16.1", "pg-types": "2.2.0", "pgpass": "1.0.5" }, @@ -2507,7 +2332,7 @@ "node": ">= 16.0.0" }, "optionalDependencies": { - "pg-cloudflare": "^1.4.0" + "pg-cloudflare": "^1.4.1" }, "peerDependencies": { "pg-native": ">=3.0.1" @@ -2519,16 +2344,16 @@ } }, "node_modules/pg-cloudflare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", - "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.1.tgz", + "integrity": "sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ==", "license": "MIT", "optional": true }, "node_modules/pg-connection-string": { - "version": "2.14.0", - "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz", - "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==", + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.1.tgz", + "integrity": "sha512-qR3kGNPBLpCNtz0evbKA0Y/MRFXwSSdT+pTJvYp/bXTcReZbvX1kzF0IyTc1QnxqF7AZbOeBhNL8R5mYQZV/MA==", "license": "MIT" }, "node_modules/pg-int8": { @@ -2550,9 +2375,9 @@ } }, "node_modules/pg-protocol": { - "version": "1.16.0", - "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.0.tgz", - "integrity": "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==", + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.1.tgz", + "integrity": "sha512-p9VOFMiHB/ZbJATetbg+99PxssTVSQRnyuPSQ67mN1+1KBOjZaZ83ZQzltnxPhJwSsC3nwVjJ10DVJlerbFzLg==", "license": "MIT" }, "node_modules/pg-types": { @@ -2669,6 +2494,26 @@ "node": ">=6" } }, + "node_modules/qified": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/qified/-/qified-0.10.1.tgz", + "integrity": "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^2.1.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/qified/node_modules/hookified": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-2.2.0.tgz", + "integrity": "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==", + "dev": true, + "license": "MIT" + }, "node_modules/qs": { "version": "6.16.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", @@ -2685,60 +2530,68 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/railway": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/railway/-/railway-3.12.0.tgz", + "integrity": "sha512-a6th+76NE3fZ8QMTOtEIe15sPTjObWOJYx0pCHCR/rd00r++I2rZHLKTn2R1qmPAwvGdAj6vY45GDVJs0AzOXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-typed-document-node/core": "^3.2.0", + "graphql": "^16.10.0", + "tsx": "^4.20.0" + }, + "bin": { + "railway-iac-ts": "dist/iac/bin.js" + }, + "engines": { + "node": ">=22" + } + }, "node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", "license": "MIT", "engines": { "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/raw-body": { - "version": "2.5.3", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", - "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", "license": "MIT", "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", - "iconv-lite": "~0.4.24", + "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" }, "engines": { - "node": ">= 0.8" + "node": ">= 0.10" } }, - "node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, "engines": { - "node": ">=4" + "node": ">= 18" } }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -2759,57 +2612,48 @@ } }, "node_modules/send": { - "version": "0.19.2", - "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", - "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", "license": "MIT", "dependencies": { - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "fresh": "~0.5.2", - "http-errors": "~2.0.1", - "mime": "1.6.0", - "ms": "2.1.3", - "on-finished": "~2.4.1", - "range-parser": "~1.2.1", - "statuses": "~2.0.2" + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" }, "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/send/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", - "dependencies": { - "ms": "2.0.0" + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/send/node_modules/debug/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, "node_modules/serve-static": { - "version": "1.16.3", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", - "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", "license": "MIT", "dependencies": { - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "parseurl": "~1.3.3", - "send": "~0.19.1" + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" }, "engines": { - "node": ">= 0.8.0" + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/setprototypeof": { @@ -2931,32 +2775,6 @@ "node": ">= 0.8" } }, - "node_modules/strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -3029,22 +2847,40 @@ } }, "node_modules/type-is": { - "version": "1.6.18", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", - "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", "license": "MIT", "dependencies": { - "media-typer": "0.3.0", - "mime-types": "~2.1.24" + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" }, "engines": { - "node": ">= 0.6" + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "dev": true, "license": "Apache-2.0", "bin": { @@ -3056,16 +2892,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.70.1", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.70.1.tgz", - "integrity": "sha512-AcWG7KDjZ2THNXsgwttMaGmzVi0VFRlFYfqFHYQRbDpF3owuYbuiL8c7UUrd2k8s3PoSfIQrWfrGXfcElrWLYA==", + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.71.0.tgz", + "integrity": "sha512-fBdHYiqQ14RW6mOMXD14Svn82ZsCYAoQSzGRzyEjR59S5A2Krh/l7fGTOQ7iCr8gGy/mHVXtEF7s5fgjEdV0Pw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.70.1", - "@typescript-eslint/parser": "8.70.1", - "@typescript-eslint/typescript-estree": "8.70.1", - "@typescript-eslint/utils": "8.70.1" + "@typescript-eslint/eslint-plugin": "8.71.0", + "@typescript-eslint/parser": "8.71.0", + "@typescript-eslint/typescript-estree": "8.71.0", + "@typescript-eslint/utils": "8.71.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3080,9 +2916,9 @@ } }, "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", "dev": true, "license": "MIT" }, @@ -3105,15 +2941,6 @@ "punycode": "^2.1.0" } }, - "node_modules/utils-merge": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", - "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", - "license": "MIT", - "engines": { - "node": ">= 0.4.0" - } - }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -3149,6 +2976,12 @@ "node": ">=0.10.0" } }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/package.json b/package.json index 9031c9f..c981729 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "private": true, "type": "module", "engines": { - "node": ">=20.11" + "node": ">=24" }, "scripts": { "dev": "tsx watch src/server.ts", @@ -13,21 +13,27 @@ "lint": "eslint .", "typecheck": "tsc --noEmit", "test": "node --import tsx --test test/*.test.ts", - "migrate": "tsx src/db/migrate.ts" + "migrate": "tsx src/db/migrate.ts", + "eval:setup": "tsx scripts/eval-setup.ts" }, "dependencies": { - "express": "^4.19.2", - "pg": "^8.12.0", - "@noctusoft/store-client": "file:packages/store-client" + "@noctusoft/store-client": "file:packages/store-client", + "express": "^5.2.1", + "pg": "^8.23.1" }, "devDependencies": { - "@electric-sql/pglite": "^0.2.12", - "@types/express": "^4.17.21", - "@types/node": "^22.5.0", + "@electric-sql/pglite": "^0.5.8", + "@types/express": "^5.0.6", + "@types/node": "^24.19.0", "@types/pg": "^8.11.6", - "eslint": "^9.9.0", - "typescript": "^5.5.4", - "typescript-eslint": "^8.3.0", - "tsx": "^4.19.0" + "eslint": "^10.11.0", + "railway": "^3.12.0", + "tsx": "^4.23.15", + "typescript": "^6.0.3", + "typescript-eslint": "^8.71.0" + }, + "allowScripts": { + "esbuild": true, + "fsevents": true } } diff --git a/railway.json b/railway.json deleted file mode 100644 index 51b22b7..0000000 --- a/railway.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "$schema": "https://railway.com/railway.schema.json", - "build": { - "builder": "RAILPACK", - "buildCommand": "npm run build" - }, - "deploy": { - "startCommand": "npm start", - "healthcheckPath": "/" - } -} diff --git a/scripts/eval-setup.ts b/scripts/eval-setup.ts new file mode 100644 index 0000000..93023bb --- /dev/null +++ b/scripts/eval-setup.ts @@ -0,0 +1,167 @@ +/** + * Voice-setup extraction against the real model on litellm-vm. + * + * Not part of `npm test`: it calls the live model and spends from the + * Coachatron LiteLLM key. Run it on purpose, with the key in the shell: + * + * LITELLM_API_KEY=... npm run eval:setup + * LITELLM_API_KEY=... npm run eval:setup -- --case keeper-basic + * + * Cases live in eval/setup-utterances.json. Each case runs the same prompt, + * schema, and validation as production (src/domain/setup.ts), then checks + * the facts the case expects. Exit code 1 if any case misses. + */ +import { readFileSync } from 'node:fs'; +import { productionComplete } from '../src/llm/complete.js'; +import { SETUP_JSON_SCHEMA } from '../src/llm/setupSchema.js'; +import { + SETUP_MODEL_TIMEOUT_MS, + setupSystemPrompt, + validateSetupPlan, + type SetupPlan, + type SetupType, +} from '../src/domain/setup.js'; +import type { CoachRow } from '../src/domain/auth.js'; + +interface ExpectType { + name_includes?: string; + duration_min?: number; + capacity?: number; + price_cents?: number | null; + backup_pay_cents?: number; +} +interface ExpectSlot { + type_name_includes?: string; + weekday: number; + time_local: string; +} +interface Case { + id: string; + text: string; + draft_text?: string; + expect: { + coach_timezone?: string; + types?: ExpectType[]; + weekly?: ExpectSlot[]; + forbid_weekly?: ExpectSlot[]; + packages?: Array<{ credits: number; price_cents: number }>; + plans?: Array<{ price_cents: number; credits_per_month: number }>; + questions_include?: string[]; + forbid_in_output?: string[]; + }; +} + +const COACH: CoachRow = { + id: 0, + handle: 'eval-coach', + name: 'Eval Coach', + email: '', + phone: '+15550000000', + tz: 'America/Chicago', + connect_recipient_key: null, +}; +const NOW = new Date('2026-10-20T15:00:00Z'); + +async function extract(text: string, draft: { plan: SetupPlan; questions: string[] } | null): Promise { + return productionComplete({ + messages: [ + { role: 'system', content: setupSystemPrompt(COACH, [], draft, NOW) }, + { role: 'user', content: text }, + ], + schema: SETUP_JSON_SCHEMA as unknown as Record, + schemaName: 'setup', + timeoutMs: SETUP_MODEL_TIMEOUT_MS, + }); +} + +function typeMatches(t: SetupType, e: ExpectType): boolean { + if (e.name_includes && !t.name.toLowerCase().includes(e.name_includes.toLowerCase())) return false; + for (const k of ['duration_min', 'capacity', 'price_cents', 'backup_pay_cents'] as const) { + if (e[k] !== undefined && t[k] !== e[k]) return false; + } + return true; +} + +function slotMatches(plan: SetupPlan, e: ExpectSlot): boolean { + return plan.weekly.some((w) => { + if (w.weekday !== e.weekday || w.time_local !== e.time_local) return false; + if (!e.type_name_includes) return true; + const type = plan.session_types.find((t) => t.key === w.type_key); + return Boolean(type?.name.toLowerCase().includes(e.type_name_includes.toLowerCase())); + }); +} + +function check(c: Case, raw: unknown, plan: SetupPlan, questions: string[]): string[] { + const misses: string[] = []; + const x = c.expect; + if (x.coach_timezone !== undefined && plan.coach_timezone !== x.coach_timezone) { + misses.push(`timezone ${plan.coach_timezone} != ${x.coach_timezone}`); + } + for (const e of x.types ?? []) { + if (!plan.session_types.some((t) => typeMatches(t, e))) misses.push(`no type like ${JSON.stringify(e)}`); + } + for (const e of x.weekly ?? []) { + if (!slotMatches(plan, e)) misses.push(`no weekly ${JSON.stringify(e)}`); + } + for (const e of x.forbid_weekly ?? []) { + if (slotMatches(plan, e)) misses.push(`unexpected weekly ${JSON.stringify(e)}`); + } + for (const e of x.packages ?? []) { + if (!plan.packages.some((p) => p.credits === e.credits && p.price_cents === e.price_cents)) { + misses.push(`no package ${JSON.stringify(e)}`); + } + } + for (const e of x.plans ?? []) { + if (!plan.plans.some((p) => p.price_cents === e.price_cents && p.credits_per_month === e.credits_per_month)) { + misses.push(`no plan ${JSON.stringify(e)}`); + } + } + for (const q of x.questions_include ?? []) { + if (!questions.some((have) => have.includes(q))) misses.push(`no question containing "${q}"`); + } + const rawText = JSON.stringify(raw); + for (const s of x.forbid_in_output ?? []) { + if (rawText.includes(s)) misses.push(`model output contains "${s}"`); + } + return misses; +} + +async function main(): Promise { + if (!process.env.LITELLM_API_KEY) { + console.error('LITELLM_API_KEY is not set. This eval calls the live model on purpose; set the key to run it.'); + process.exit(2); + } + const only = process.argv.includes('--case') ? process.argv[process.argv.indexOf('--case') + 1] : null; + const file = JSON.parse(readFileSync(new URL('../eval/setup-utterances.json', import.meta.url), 'utf8')) as { cases: Case[] }; + const cases = file.cases.filter((c) => !only || c.id === only); + + let passed = 0; + let calls = 0; + for (const c of cases) { + const started = Date.now(); + try { + let draft: { plan: SetupPlan; questions: string[] } | null = null; + if (c.draft_text) { + draft = validateSetupPlan(await extract(c.draft_text, null), []); + calls += 1; + } + const raw = await extract(c.text, draft); + calls += 1; + const { plan, questions } = validateSetupPlan(raw, []); + const misses = check(c, raw, plan, questions); + const ms = Date.now() - started; + if (misses.length === 0) { + passed += 1; + console.log(`PASS ${c.id} (${ms} ms)`); + } else { + console.log(`MISS ${c.id} (${ms} ms)\n ${misses.join('\n ')}\n plan: ${JSON.stringify(plan)}`); + } + } catch (err) { + console.log(`FAIL ${c.id}: ${(err as Error).message}`); + } + } + console.log(`\n${passed}/${cases.length} cases passed, ${calls} model calls (model: ${process.env.LITELLM_MODEL ?? 'coachatron'}).`); + process.exit(passed === cases.length ? 0 : 1); +} + +await main(); diff --git a/src/config.ts b/src/config.ts index 59fc8d8..3090d85 100644 --- a/src/config.ts +++ b/src/config.ts @@ -25,3 +25,15 @@ export const LITELLM_TIMEOUT_MS = 3000; export const ASSISTANT_CONFIRM_TTL_MS = 10 * 60 * 1000; export const ASSISTANT_MODEL_DAILY_CAP = 30; export const ASSISTANT_MODEL_MONTHLY_CAP = 400; +/** Outbound SMS ceilings (SPEC.md §10, PLATFORM.md §4.5). A loop stops here. */ +export const SMS_COACH_DAILY_CAP = 300; +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/0007_weekly_slot.sql b/src/db/migrations/0007_weekly_slot.sql new file mode 100644 index 0000000..760f85b --- /dev/null +++ b/src/db/migrations/0007_weekly_slot.sql @@ -0,0 +1,25 @@ +-- A coach's weekly pattern. Sessions are generated from it 8 weeks ahead and +-- topped up by the tick job, so a schedule does not run out (SPEC.md 7.1). +create table if not exists weekly_slot ( + id serial primary key, + session_type_id integer not null references session_type(id), + weekday integer not null, + time_local text not null, + location_text text, + active boolean not null default true, + created_at timestamptz not null default now() +); + +-- weekday is 0 (Sunday) to 6 (Saturday) and time_local is HH:MM, both in the +-- coach timezone. The unique index makes regeneration idempotent. +alter table session add column if not exists weekly_slot_id integer references weekly_slot(id); +create unique index if not exists session_slot_start on session (weekly_slot_id, starts_at_utc); + +-- What a backup coach is paid for a session of this type, shown in the offer. +alter table session_type add column if not exists backup_pay_cents integer; + +-- One 24-hour reminder per booking (SPEC.md 10). +create table if not exists reminder_sent ( + booking_id integer primary key references booking(id), + sent_at timestamptz not null default now() +); diff --git a/src/db/migrations/0008_setup_draft.sql b/src/db/migrations/0008_setup_draft.sql new file mode 100644 index 0000000..b1f6d9a --- /dev/null +++ b/src/db/migrations/0008_setup_draft.sql @@ -0,0 +1,14 @@ +-- Voice setup: what the coach said, what we understood, and what we still +-- need to ask. Nothing is written to the schedule until the coach taps +-- Publish on the rendered preview (PLATFORM.md 4.3 R5 exception). +create table if not exists setup_draft ( + id serial primary key, + coach_id integer not null references coach(id), + source_text text not null, + plan text not null, + questions text not null, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + published_at timestamptz, + discarded_at timestamptz +); diff --git a/src/db/migrations/0009_message_log.sql b/src/db/migrations/0009_message_log.sql new file mode 100644 index 0000000..20a2724 --- /dev/null +++ b/src/db/migrations/0009_message_log.sql @@ -0,0 +1,7 @@ +-- Every outbound text is logged here, so the send path can enforce the +-- ceilings in SPEC.md 10 and PLATFORM.md 4.5 (per coach per day and month, +-- per product per day, one segment, domestic destinations only). +alter table message_log add column if not exists coach_id integer references coach(id); +create index if not exists message_log_sent_at on message_log (sent_at); +create index if not exists message_log_coach_sent on message_log (coach_id, sent_at); +create index if not exists message_log_to_sent on message_log (to_phone, sent_at); 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/assistant.ts b/src/domain/assistant.ts index af921c6..f23a822 100644 --- a/src/domain/assistant.ts +++ b/src/domain/assistant.ts @@ -3,11 +3,9 @@ import type { CoachRow } from './auth.js'; import { startCascade, isOptedOut } from './cascade.js'; import { summarizeMoney } from './money.js'; import { addCalendarDays, zonedParts, zonedTimeToUtc } from './scheduling.js'; -import { - APP_BASE_URL, - ASSISTANT_MODEL_DAILY_CAP, - ASSISTANT_MODEL_MONTHLY_CAP, -} from '../config.js'; +import { APP_BASE_URL } from '../config.js'; +import { reserveModelCall } from './modelBudget.js'; +import { coachHasSchedule, handleSetupMessage, setupSmsReply } from './setup.js'; import { complete } from '../llm/complete.js'; import { INTENT_JSON_SCHEMA, @@ -17,7 +15,7 @@ import { type IntentName, } from '../llm/schema.js'; import { clipSms, formatConfirmWhen, formatLocal } from '../lib/time.js'; -import { sendSms } from '../relay/sms.js'; +import { sendText } from './outbound.js'; import { consumePending, createPending, @@ -283,25 +281,6 @@ export function classifiedFromModel(raw: unknown): ClassifiedIntent { }; } -async function modelCallCount(db: DbClient, coachId: number, sinceIso: string): Promise { - const result = await db.query<{ n: string }>( - 'select count(*)::text as n from assistant_model_call where coach_id = $1 and called_at >= $2', - [coachId, sinceIso], - ); - return Number(result.rows[0]?.n ?? '0'); -} - -async function underModelCap(db: DbClient, coach: CoachRow, now: Date): Promise { - const local = zonedParts(now, coach.tz); - const dayStart = zonedTimeToUtc(local.year, local.month, local.day, 0, 0, coach.tz); - const monthStart = zonedTimeToUtc(local.year, local.month, 1, 0, 0, coach.tz); - const [dayCount, monthCount] = await Promise.all([ - modelCallCount(db, coach.id, dayStart.toISOString()), - modelCallCount(db, coach.id, monthStart.toISOString()), - ]); - return dayCount < ASSISTANT_MODEL_DAILY_CAP && monthCount < ASSISTANT_MODEL_MONTHLY_CAP; -} - function sessionCatalog(sessions: SessionBrief[]): string { return sessions .map((s) => { @@ -320,7 +299,7 @@ async function llmClassify( now: Date, channel: AssistantChannel, ): Promise { - if (!(await underModelCap(db, coach, now))) { + if (!(await reserveModelCall(db, coach, now))) { await logAssistant(db, { coachId: coach.id, phone: coach.phone, @@ -333,8 +312,6 @@ async function llmClassify( return unknownIntent('model'); } - await db.query('insert into assistant_model_call (coach_id, called_at) values ($1, $2)', [coach.id, now.toISOString()]); - const typeLines = types.map((t) => `- id=${t.id} name="${t.name}"`).join('\n'); const system = [ 'Classify one message from a coach into a single intent object. JSON only.', @@ -566,9 +543,11 @@ async function executeCancel(db: DbClient, coach: CoachRow, sessionId: number): credits += 1; } if (!(await isOptedOut(db, athlete.contact_phone))) { - await sendSms({ + 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', }); } } @@ -651,8 +630,8 @@ 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; - await sendSms({ to: athlete.contact_phone, body }); - sent += 1; + const outcome = await sendText(db, { to: athlete.contact_phone, body, coachId: coach.id, template: 'broadcast' }); + if (outcome === 'sent') sent += 1; } return clipSms(`Sent to ${sent} athletes.`); } @@ -802,6 +781,14 @@ export async function handleCoachMessage( await consumePending(db, pending.id, now); } + // A coach with nothing on the calendar is setting up, not asking about a + // week: their text goes to voice setup, which only saves a draft. + if (channel === 'sms' && kw === null && !(await coachHasSchedule(db, coach.id, now))) { + const text = clipSms(setupSmsReply(await handleSetupMessage(db, coach, trimmed, 'sms', now))); + await setLastReply(db, coach.id, text, null, now); + return { text, pendingId: null, kind: 'answer' }; + } + const sessions = await listUpcomingSessions(db, coach.id, now); const types = await loadSessionTypes(db, coach.id); const classified = await classifyUtterance(db, coach, trimmed, sessions, types, now, channel); diff --git a/src/domain/cascade.ts b/src/domain/cascade.ts index 0c76e86..244ac99 100644 --- a/src/domain/cascade.ts +++ b/src/domain/cascade.ts @@ -1,7 +1,8 @@ import { randomBytes } from 'node:crypto'; import type { DbClient } from '../db/client.js'; -import { sendSms } from '../relay/sms.js'; +import { sendText } from './outbound.js'; import { expireLivePendingForCoach } from './assistantPending.js'; +import { clipSms, formatConfirmWhen } from '../lib/time.js'; export const OVERFLOW_OFFER_TTL_MINUTES = 20; @@ -17,9 +18,9 @@ export async function upsertOptOut(db: DbClient, phone: string): Promise { /** 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." */ -async function sendUnlessOptedOut(db: DbClient, to: string, body: string): Promise { +async function sendUnlessOptedOut(db: DbClient, coachId: number, template: string, to: string, body: string): Promise { if (await isOptedOut(db, to)) return; - await sendSms({ to, body }); + await sendText(db, { to, body, coachId, template }); } interface SessionOverflowInfo { @@ -27,6 +28,9 @@ interface SessionOverflowInfo { coachPhone: string; capacity: number; overflowThreshold: number; + /** "Tue Oct 7, 6:00pm Keeper Group", for the texts. */ + label: string; + backupPayCents: number | null; } async function loadSessionOverflowInfo(db: DbClient, sessionId: number): Promise { @@ -36,8 +40,13 @@ async function loadSessionOverflowInfo(db: DbClient, sessionId: number): Promise capacity: number; capacity_override: number | null; overflow_threshold: number; + starts_at_utc: string | Date; + tz: string; + name: string; + backup_pay_cents: number | null; }>( - `select c.id as coach_id, c.phone as coach_phone, st.capacity, s.capacity_override, st.overflow_threshold + `select c.id as coach_id, c.phone as coach_phone, st.capacity, s.capacity_override, st.overflow_threshold, + s.starts_at_utc, s.tz, st.name, st.backup_pay_cents from session s join session_type st on st.id = s.session_type_id join coach c on c.id = st.coach_id @@ -52,6 +61,8 @@ async function loadSessionOverflowInfo(db: DbClient, sessionId: number): Promise coachPhone: row.coach_phone, capacity, overflowThreshold: row.overflow_threshold >= 0 ? row.overflow_threshold : capacity, + label: `${formatConfirmWhen(new Date(row.starts_at_utc).toISOString(), row.tz)} ${row.name}`, + backupPayCents: row.backup_pay_cents, }; } @@ -105,8 +116,10 @@ export async function checkOverflow(db: DbClient, sessionId: number): Promise { + const info = await loadSessionOverflowInfo(db, sessionId); + if (!info) return null; + const booked = await countBooked(db, sessionId); + const pay = info.backupPayCents ? ` Pays ${formatPay(info.backupPayCents)}.` : ''; + return `${info.label}, ${booked} athletes.${pay}`; } /** Declining advances the cascade immediately, without waiting for the diff --git a/src/domain/modelBudget.ts b/src/domain/modelBudget.ts new file mode 100644 index 0000000..a571829 --- /dev/null +++ b/src/domain/modelBudget.ts @@ -0,0 +1,28 @@ +import type { DbClient } from '../db/client.js'; +import type { CoachRow } from './auth.js'; +import { zonedParts, zonedTimeToUtc } from './scheduling.js'; +import { ASSISTANT_MODEL_DAILY_CAP, ASSISTANT_MODEL_MONTHLY_CAP } from '../config.js'; + +async function modelCallCount(db: DbClient, coachId: number, sinceIso: string): Promise { + const result = await db.query<{ n: string }>( + 'select count(*)::text as n from assistant_model_call where coach_id = $1 and called_at >= $2', + [coachId, sinceIso], + ); + return Number(result.rows[0]?.n ?? '0'); +} + +/** Claims one model call against the coach's day and month caps (SPEC.md + * §10). Every caller of the model shares this budget. Returns false, and + * records nothing, once a cap is reached. */ +export async function reserveModelCall(db: DbClient, coach: CoachRow, now: Date): Promise { + const local = zonedParts(now, coach.tz); + const dayStart = zonedTimeToUtc(local.year, local.month, local.day, 0, 0, coach.tz); + const monthStart = zonedTimeToUtc(local.year, local.month, 1, 0, 0, coach.tz); + const [dayCount, monthCount] = await Promise.all([ + modelCallCount(db, coach.id, dayStart.toISOString()), + modelCallCount(db, coach.id, monthStart.toISOString()), + ]); + if (dayCount >= ASSISTANT_MODEL_DAILY_CAP || monthCount >= ASSISTANT_MODEL_MONTHLY_CAP) return false; + await db.query('insert into assistant_model_call (coach_id, called_at) values ($1, $2)', [coach.id, now.toISOString()]); + return true; +} diff --git a/src/domain/outbound.ts b/src/domain/outbound.ts new file mode 100644 index 0000000..ee126b5 --- /dev/null +++ b/src/domain/outbound.ts @@ -0,0 +1,167 @@ +import type { DbClient } from '../db/client.js'; +import { sendSms } from '../relay/sms.js'; +import { zonedParts, zonedTimeToUtc } from './scheduling.js'; +import { + SMS_COACH_DAILY_CAP, + SMS_COACH_MONTHLY_CAP, + SMS_PRODUCT_DAILY_FLOOR, + SMS_PRODUCT_DAILY_PER_COACH, +} from '../config.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 + * against the coach's day and month and the product's day before it goes. */ + +const GSM_BASIC = new Set( + '@£$¥èéùìòÇ\nØø\rÅåΔ_ΦΓΛΩΠΨΣΘΞÆæßÉ !"#¤%&\'()*+,-./0123456789:;<=>?¡ABCDEFGHIJKLMNOPQRSTUVWXYZÄÖÑܧ¿abcdefghijklmnopqrstuvwxyzäöñüà', +); +const GSM_EXTENDED = new Set('^{}\\[~]|€'); +const GSM_SEGMENT = 160; +const UCS2_SEGMENT = 70; + +// Typographic characters that would force the whole text into UCS-2 (70 +// characters a segment) for no reason. +const TRANSLITERATE: Array<[RegExp, string]> = [ + [/[‘’‚′]/g, "'"], + [/[“”„″]/g, '"'], + [/…/g, '...'], + [/[–—−]/g, '-'], + [/[·•]/g, '-'], + [/[   ]/g, ' '], +]; + +function gsmLength(text: string): number | null { + let n = 0; + for (const ch of text) { + if (GSM_BASIC.has(ch)) n += 1; + else if (GSM_EXTENDED.has(ch)) n += 2; + else return null; + } + return n; +} + +/** Fits a text into one SMS segment: GSM-7 up to 160, otherwise UCS-2 up + * to 70. PLATFORM.md §4.5: "a second segment is never sent." */ +export function toOneSegment(body: string): string { + let text = body.trim(); + for (const [re, to] of TRANSLITERATE) text = text.replace(re, to); + const gsm = gsmLength(text); + if (gsm !== null) { + if (gsm <= GSM_SEGMENT) return text; + let out = ''; + let used = 0; + for (const ch of text) { + const w = GSM_EXTENDED.has(ch) ? 2 : 1; + if (used + w > GSM_SEGMENT - 3) break; + out += ch; + used += w; + } + return `${out.trimEnd()}...`; + } + // UCS-2 counts UTF-16 code units; never split a surrogate pair. + if (text.length <= UCS2_SEGMENT) return text; + let out = ''; + for (const ch of text) { + if (out.length + ch.length > UCS2_SEGMENT - 3) break; + out += ch; + } + return `${out.trimEnd()}...`; +} + +export function segmentCount(body: string): number { + const gsm = gsmLength(body); + return gsm !== null ? Math.max(1, Math.ceil(gsm / GSM_SEGMENT)) : Math.max(1, Math.ceil(body.length / UCS2_SEGMENT)); +} + +/** US and Canada only: the NANP is what Twilio prices near $0.0083 a + * segment. Anything else can cost many times that, so it is refused + * (PLATFORM.md §4.5: "above $0.02/segment is refused"). */ +export function isDomesticDestination(phone: string): boolean { + return /^\+1\d{10}$/.test(phone); +} + +export type SendOutcome = 'sent' | 'capped' | 'refused'; + +export interface OutboundText { + to: string; + body: string; + /** The coach this text is sent for; null for sign-in codes and replies to unknown numbers. */ + coachId: number | null; + /** A short name for the kind of text, e.g. "reminder", "offer", "otp". */ + template: string; + /** Optional ceiling on this template to this number in 24 hours. */ + perRecipientDailyMax?: number; +} + +async function countSent(db: DbClient, where: string, params: unknown[]): Promise { + const result = await db.query<{ n: string }>( + `select count(*)::text as n from message_log where status = 'sent' and ${where}`, + params, + ); + return Number(result.rows[0]?.n ?? '0'); +} + +async function productDailyCeiling(db: DbClient, now: Date): Promise { + const active = await db.query<{ n: string }>( + `select count(distinct st.coach_id)::text as n + from session s join session_type st on st.id = s.session_type_id + where s.status = 'scheduled' and s.starts_at_utc > $1`, + [now.toISOString()], + ); + return Math.max(SMS_PRODUCT_DAILY_FLOOR, SMS_PRODUCT_DAILY_PER_COACH * Number(active.rows[0]?.n ?? '0')); +} + +/** Which ceiling, if any, this text would cross. */ +async function ceilingHit(db: DbClient, msg: OutboundText, now: Date): Promise { + if (msg.perRecipientDailyMax !== undefined) { + const since = new Date(now.getTime() - 24 * 60 * 60 * 1000).toISOString(); + const n = await countSent(db, 'to_phone = $1 and template = $2 and sent_at > $3', [msg.to, msg.template, since]); + if (n >= msg.perRecipientDailyMax) return 'recipient-daily'; + } + if (msg.coachId !== null) { + const coach = await db.query<{ tz: string }>('select tz from coach where id = $1', [msg.coachId]); + const tz = coach.rows[0]?.tz ?? 'America/Chicago'; + const local = zonedParts(now, tz); + const dayStart = zonedTimeToUtc(local.year, local.month, local.day, 0, 0, tz).toISOString(); + const monthStart = zonedTimeToUtc(local.year, local.month, 1, 0, 0, tz).toISOString(); + if ((await countSent(db, 'coach_id = $1 and sent_at >= $2', [msg.coachId, dayStart])) >= SMS_COACH_DAILY_CAP) { + return 'coach-daily'; + } + if ((await countSent(db, 'coach_id = $1 and sent_at >= $2', [msg.coachId, monthStart])) >= SMS_COACH_MONTHLY_CAP) { + return 'coach-monthly'; + } + } + const utcDay = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())).toISOString(); + if ((await countSent(db, 'sent_at >= $1', [utcDay])) >= (await productDailyCeiling(db, now))) return 'product-daily'; + return null; +} + +async function log(db: DbClient, msg: OutboundText, body: string, status: string, providerId: string | null, now: Date) { + await db.query( + `insert into message_log (to_phone, template, body, provider_id, sent_at, status, coach_id) + values ($1, $2, $3, $4, $5, $6, $7)`, + [msg.to, msg.template, body, providerId, now.toISOString(), status, msg.coachId], + ); +} + +export async function sendText(db: DbClient, msg: OutboundText, now: Date = new Date()): Promise { + const body = toOneSegment(msg.body); + if (!isDomesticDestination(msg.to)) { + await log(db, msg, body, 'refused-destination', 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); + return 'sent'; + } catch (err) { + await log(db, msg, body, 'failed', null, now); + throw err; + } +} diff --git a/src/domain/scheduling.ts b/src/domain/scheduling.ts index 61c65db..cd74ddb 100644 --- a/src/domain/scheduling.ts +++ b/src/domain/scheduling.ts @@ -76,58 +76,130 @@ export function addCalendarDays( } export interface WeeklySlot { - /** 0 = Sunday .. 6 = Saturday, matching Date#getUTCDay via the coach's tz */ + /** 0 = Sunday .. 6 = Saturday, in the coach's tz */ weekday: number; /** "HH:MM" 24-hour, local to the coach's tz */ timeLocal: string; + locationText?: string | null; } -export interface SessionTypeRow { +export interface SlotRow { id: number; - coach_id: number; - name: string; - duration_min: number; - capacity: number; - price_cents: number; + session_type_id: number; + weekday: number; + time_local: string; + location_text: string | null; } -/** Generates session rows for the next 7 days starting today (coach's tz), - * for each requested weekly slot whose weekday falls in that window. */ -export async function generateWeekSessions( +/** How far ahead a weekly slot keeps sessions on the calendar (SPEC.md §7.1). */ +export const SLOT_HORIZON_DAYS = 56; + +/** Saves weekly slots for a session type and fills their first 8 weeks. */ +export async function createWeeklySlots( db: DbClient, - sessionType: SessionTypeRow, + sessionTypeId: number, tz: string, slots: WeeklySlot[], now: Date, -): Promise { - const created: number[] = []; - for (let offset = 0; offset < 7; offset += 1) { - const day = new Date(now.getTime() + offset * 24 * 60 * 60 * 1000); - const dayParts = new Intl.DateTimeFormat('en-US', { - timeZone: tz, - year: 'numeric', - month: '2-digit', - day: '2-digit', - weekday: 'short', - }).formatToParts(day); - const get = (type: string) => dayParts.find((p) => p.type === type)?.value ?? ''; - const weekdayName = get('weekday'); - const weekdayIndex = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].indexOf(weekdayName); - const year = Number(get('year')); - const month = Number(get('month')); - const dayOfMonth = Number(get('day')); - - for (const slot of slots) { - if (slot.weekday !== weekdayIndex) continue; - const [hourStr, minuteStr] = slot.timeLocal.split(':'); - const startsAtUtc = zonedTimeToUtc(year, month, dayOfMonth, Number(hourStr), Number(minuteStr), tz); - const result = await db.query<{ id: number }>( - `insert into session (session_type_id, starts_at_utc, tz, status) - values ($1, $2, $3, 'scheduled') returning id`, - [sessionType.id, startsAtUtc.toISOString(), tz], - ); - created.push(result.rows[0].id); - } +): Promise { + const created: SlotRow[] = []; + for (const slot of slots) { + const result = await db.query( + `insert into weekly_slot (session_type_id, weekday, time_local, location_text) + values ($1, $2, $3, $4) + returning id, session_type_id, weekday, time_local, location_text`, + [sessionTypeId, slot.weekday, slot.timeLocal, slot.locationText ?? null], + ); + const row = result.rows[0]; + await ensureSlotSessions(db, row, tz, now); + created.push(row); } return created; } + +/** Creates the slot's sessions from today through the horizon. Walks + * calendar days in the coach's tz (DST-safe via addCalendarDays) and relies + * on the (weekly_slot_id, starts_at_utc) unique index, so running it again + * never duplicates a session. Returns how many sessions it added. */ +export async function ensureSlotSessions( + db: DbClient, + slot: SlotRow, + tz: string, + now: Date, + horizonDays = SLOT_HORIZON_DAYS, +): Promise { + const today = zonedParts(now, tz); + const [hour, minute] = slot.time_local.split(':').map(Number); + let added = 0; + for (let offset = 0; offset <= horizonDays; offset += 1) { + const d = addCalendarDays(today.year, today.month, today.day, offset); + if (new Date(Date.UTC(d.year, d.month - 1, d.day)).getUTCDay() !== slot.weekday) continue; + const startsAtUtc = zonedTimeToUtc(d.year, d.month, d.day, hour, minute, tz); + if (startsAtUtc <= now) continue; + const result = await db.query<{ id: number }>( + `insert into session (session_type_id, starts_at_utc, tz, location_text, weekly_slot_id, status) + values ($1, $2, $3, $4, $5, 'scheduled') + on conflict (weekly_slot_id, starts_at_utc) do nothing + returning id`, + [slot.session_type_id, startsAtUtc.toISOString(), tz, slot.location_text, slot.id], + ); + added += result.rows.length; + } + return added; +} + +/** Tick step: keeps every active slot of an active session type 8 weeks ahead. */ +export async function topUpAllSlots(db: DbClient, now: Date): Promise { + const slots = await db.query( + `select ws.id, ws.session_type_id, ws.weekday, ws.time_local, ws.location_text, c.tz + from weekly_slot ws + join session_type st on st.id = ws.session_type_id + join coach c on c.id = st.coach_id + where ws.active = true and st.active = true`, + ); + let added = 0; + for (const slot of slots.rows) { + added += await ensureSlotSessions(db, slot, slot.tz, now); + } + return added; +} + +export async function listWeeklySlots(db: DbClient, sessionTypeId: number): Promise { + const result = await db.query( + `select id, session_type_id, weekday, time_local, location_text + from weekly_slot + where session_type_id = $1 and active = true + order by weekday, time_local`, + [sessionTypeId], + ); + return result.rows; +} + +/** Stops a weekly slot. Its future sessions with nobody booked are + * cancelled quietly; sessions with bookings stay so the coach can cancel + * them one at a time (which texts the athletes). Returns how many stayed, + * or null if the slot is not this coach's. */ +export async function deactivateSlot(db: DbClient, coachId: number, slotId: number, now: Date): Promise { + const owned = await db.query<{ id: number }>( + `update weekly_slot ws set active = false + from session_type st + where ws.id = $1 and st.id = ws.session_type_id and st.coach_id = $2 + returning ws.id`, + [slotId, coachId], + ); + if (owned.rows.length === 0) return null; + await db.query( + `update session s set status = 'cancelled' + where s.weekly_slot_id = $1 and s.status = 'scheduled' and s.starts_at_utc > $2 + and not exists ( + select 1 from booking b where b.session_id = s.id and b.status in ('booked', 'pending') + )`, + [slotId, now.toISOString()], + ); + const kept = await db.query<{ n: string }>( + `select count(*)::text as n from session + where weekly_slot_id = $1 and status = 'scheduled' and starts_at_utc > $2`, + [slotId, now.toISOString()], + ); + return Number(kept.rows[0]?.n ?? '0'); +} diff --git a/src/domain/setup.ts b/src/domain/setup.ts new file mode 100644 index 0000000..ea85cb3 --- /dev/null +++ b/src/domain/setup.ts @@ -0,0 +1,572 @@ +import type { DbClient } from '../db/client.js'; +import type { CoachRow } from './auth.js'; +import { reserveModelCall } from './modelBudget.js'; +import { logAssistant } from './assistantPending.js'; +import { createPackage, createPlan } from './pricing.js'; +import { addCalendarDays, createWeeklySlots, zonedParts, zonedTimeToUtc, type WeeklySlot } from './scheduling.js'; +import { complete } from '../llm/complete.js'; +import { SETUP_JSON_SCHEMA } from '../llm/setupSchema.js'; +import { html, raw, type SafeHtml } from '../lib/html.js'; +import { formatLocal, isValidTimeZone } from '../lib/time.js'; +import { APP_BASE_URL } from '../config.js'; + +/** Voice setup: the coach says their week, the model extracts it into + * SETUP_JSON_SCHEMA, and everything after that is product code. Nothing is + * written to the schedule until the coach taps Publish on a preview this + * module rendered (PLATFORM.md §4.3 R2, and the R5 exception for additive + * setup). */ + +export interface SetupType { + key: string; + name: string; + duration_min: number; + capacity: number | null; + price_cents: number | null; + backup_pay_cents: number | null; + /** Set when the name matches a session type the coach already has. */ + existing_id: number | null; +} + +export interface SetupSlot { + type_key: string; + weekday: number; + time_local: string; + location: string | null; +} + +export interface SetupPackage { + name: string; + credits: number; + price_cents: number; + expires_days: number | null; +} + +export interface SetupPlanOffer { + name: string; + price_cents: number; + credits_per_month: number; +} + +export interface SetupPlan { + coach_timezone: string | null; + session_types: SetupType[]; + weekly: SetupSlot[]; + packages: SetupPackage[]; + plans: SetupPlanOffer[]; +} + +export interface ExistingType { + id: number; + name: string; + duration_min: number; + capacity: number; + price_cents: number; + backup_pay_cents: number | null; +} + +export const SETUP_LIMITS = { types: 6, weekly: 40, packages: 4, plans: 3 }; +export const SETUP_MODEL_TIMEOUT_MS = 8000; +const DRAFT_TTL_MS = 7 * 24 * 60 * 60 * 1000; +const DEFAULT_DURATION = 60; +const PRIVATE_NAME = /private|1[- ]?on[- ]?1|one[- ]on[- ]one|lesson/i; +const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; + +function asRecord(value: unknown): Record { + return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record) : {}; +} + +function asArray(value: unknown): unknown[] { + return Array.isArray(value) ? value : []; +} + +function intIn(value: unknown, min: number, max: number): number | null { + const n = typeof value === 'string' && /^\d+$/.test(value.trim()) ? Number(value) : value; + return typeof n === 'number' && Number.isInteger(n) && n >= min && n <= max ? n : null; +} + +function name(value: unknown): string | null { + if (typeof value !== 'string') return null; + const t = value.trim().replace(/\s+/g, ' '); + return t.length >= 1 && t.length <= 60 ? t : null; +} + +function timeLocal(value: unknown): string | null { + const m = typeof value === 'string' ? /^(\d{1,2}):(\d{2})$/.exec(value.trim()) : null; + if (!m) return null; + const hour = Number(m[1]); + const minute = Number(m[2]); + if (hour < 5 || hour > 23 || minute > 59) return null; + return `${String(hour).padStart(2, '0')}:${m[2]}`; +} + +/** Turns whatever the model returned into a plan the product trusts, plus + * the questions it must ask before Publish. Pure: no model, no database. */ +export function validateSetupPlan(raw: unknown, existing: ExistingType[]): { plan: SetupPlan; questions: string[] } { + const o = asRecord(raw); + const questions: string[] = []; + + let coachTimezone: string | null = null; + if (typeof o.coach_timezone === 'string' && o.coach_timezone.trim()) { + if (isValidTimeZone(o.coach_timezone.trim())) coachTimezone = o.coach_timezone.trim(); + else questions.push('What city are your sessions in, for the time zone?'); + } + + const types: SetupType[] = []; + const keyToType = new Map(); + for (const item of asArray(o.session_types)) { + const t = asRecord(item); + const typeName = name(t.name); + if (!typeName) continue; + const key = typeof t.key === 'string' && t.key ? t.key : typeName; + const same = types.find((x) => x.name.toLowerCase() === typeName.toLowerCase()); + if (same) { + keyToType.set(key, same); + continue; + } + if (types.length >= SETUP_LIMITS.types) continue; + const match = existing.find((e) => e.name.toLowerCase() === typeName.toLowerCase()); + const type: SetupType = match + ? { + key, + name: match.name, + duration_min: match.duration_min, + capacity: match.capacity, + price_cents: match.price_cents, + backup_pay_cents: match.backup_pay_cents, + existing_id: match.id, + } + : { + key, + name: typeName, + duration_min: intIn(t.duration_min, 10, 480) ?? DEFAULT_DURATION, + capacity: intIn(t.capacity, 1, 100) ?? (PRIVATE_NAME.test(typeName) ? 1 : null), + price_cents: intIn(t.price_cents, 100, 100000), + backup_pay_cents: intIn(t.backup_pay_cents, 100, 100000), + existing_id: null, + }; + types.push(type); + keyToType.set(key, type); + } + + const weekly: SetupSlot[] = []; + for (const item of asArray(o.weekly)) { + if (weekly.length >= SETUP_LIMITS.weekly) break; + const w = asRecord(item); + const type = typeof w.type_key === 'string' ? keyToType.get(w.type_key) : undefined; + const weekday = intIn(w.weekday, 0, 6); + const time = timeLocal(w.time_local); + if (!type || weekday === null || !time) continue; + if (weekly.some((x) => x.type_key === type.key && x.weekday === weekday && x.time_local === time)) continue; + const location = typeof w.location === 'string' && w.location.trim() ? w.location.trim().slice(0, 80) : null; + weekly.push({ type_key: type.key, weekday, time_local: time, location }); + } + + const packages: SetupPackage[] = []; + for (const item of asArray(o.packages)) { + if (packages.length >= SETUP_LIMITS.packages) break; + const p = asRecord(item); + const packageName = name(p.name); + const credits = intIn(p.credits, 1, 100); + const price = intIn(p.price_cents, 100, 500000); + if (!packageName || credits === null || price === null) continue; + packages.push({ name: packageName, credits, price_cents: price, expires_days: intIn(p.expires_days, 1, 730) }); + } + + const plans: SetupPlanOffer[] = []; + for (const item of asArray(o.plans)) { + if (plans.length >= SETUP_LIMITS.plans) break; + const p = asRecord(item); + const planName = name(p.name); + const price = intIn(p.price_cents, 100, 100000); + const credits = intIn(p.credits_per_month, 1, 60); + if (!planName || price === null || credits === null) continue; + plans.push({ name: planName, price_cents: price, credits_per_month: credits }); + } + + for (const type of types) { + if (type.price_cents === null) questions.push(`What do you charge for ${type.name}?`); + if (type.capacity === null) questions.push(`How many athletes fit in ${type.name}?`); + } + if (types.length === 0) { + questions.push('What do you coach? Say the name, how long, how many athletes, and the price.'); + } else if (weekly.length === 0) { + questions.push('What days and times do you run each week?'); + } + + return { + plan: { coach_timezone: coachTimezone, session_types: types, weekly, packages, plans }, + questions, + }; +} + +// ---- The model's part: extraction only ---- + +function localDateLine(now: Date, tz: string): string { + const p = zonedParts(now, tz); + return `${WEEKDAYS[p.weekday]} ${p.year}-${String(p.month).padStart(2, '0')}-${String(p.day).padStart(2, '0')}`; +} + +export function setupSystemPrompt( + coach: CoachRow, + existing: ExistingType[], + draft: { plan: SetupPlan; questions: string[] } | null, + now: Date, +): string { + const lines = [ + "Extract a coach's weekly schedule into the JSON schema. JSON only.", + 'Only what the coach said. Use null for anything not said. Never invent a price or a capacity.', + 'Prices and pay are integer cents: "$35" is 3500.', + 'Durations are minutes: "an hour" is 60, "an hour and a half" is 90.', + 'weekday: 0 Sunday, 1 Monday, 2 Tuesday, 3 Wednesday, 4 Thursday, 5 Friday, 6 Saturday.', + 'time_local is 24-hour HH:MM. A bare hour from 1 to 11 means pm unless the coach says morning or am.', + '"Tuesdays and Thursdays at 6" is two weekly entries. "Saturday 9 to noon" in 45-minute slots is 9:00, 9:45, 10:30, 11:15.', + 'Each weekly entry names its session type by key. Give every session type a short unique key.', + 'backup_pay_cents is what a backup coach is paid to run one session, if the coach says so.', + 'coach_timezone is an IANA zone only if the coach names a city or zone; otherwise null.', + 'Packages are prepaid session packs. Plans are monthly subscriptions.', + 'Leave out phone numbers, emails, and card numbers.', + `Today is ${localDateLine(now, coach.tz)} in ${coach.tz}.`, + `Session types the coach already has: ${existing.map((e) => e.name).join(', ') || '(none)'}.`, + ]; + if (draft) { + lines.push(`Current setup: ${JSON.stringify(draft.plan)}`); + if (draft.questions.length) lines.push(`Questions the coach is answering: ${draft.questions.join(' ')}`); + lines.push("Apply the coach's message to the current setup and return the full updated setup."); + } + return lines.join('\n'); +} + +export type ExtractResult = { kind: 'ok'; raw: unknown } | { kind: 'cap' } | { kind: 'error' }; + +export async function extractSetupPlan( + db: DbClient, + coach: CoachRow, + text: string, + existing: ExistingType[], + draft: { plan: SetupPlan; questions: string[] } | null, + now: Date, + channel: 'sms' | 'web', +): Promise { + if (!(await reserveModelCall(db, coach, now))) { + await logAssistant(db, { coachId: coach.id, phone: coach.phone, channel, rawMessage: text, layer: 'setup', outcome: 'cap' }); + return { kind: 'cap' }; + } + try { + const raw = await complete({ + messages: [ + { role: 'system', content: setupSystemPrompt(coach, existing, draft, now) }, + { role: 'user', content: text }, + ], + schema: SETUP_JSON_SCHEMA as unknown as Record, + schemaName: 'setup', + timeoutMs: SETUP_MODEL_TIMEOUT_MS, + }); + await logAssistant(db, { + coachId: coach.id, + phone: coach.phone, + channel, + rawMessage: text, + layer: 'setup', + payload: raw, + outcome: 'extracted', + }); + return { kind: 'ok', raw }; + } catch { + await logAssistant(db, { coachId: coach.id, phone: coach.phone, channel, rawMessage: text, layer: 'setup', outcome: 'error' }); + return { kind: 'error' }; + } +} + +// ---- Drafts ---- + +export interface SetupDraft { + id: number; + plan: SetupPlan; + questions: string[]; +} + +export async function loadExistingTypes(db: DbClient, coachId: number): Promise { + const result = await db.query( + `select id, name, duration_min, capacity, price_cents, backup_pay_cents + from session_type where coach_id = $1 and active = true order by id`, + [coachId], + ); + return result.rows; +} + +export async function getOpenDraft(db: DbClient, coachId: number, now: Date): Promise { + const result = await db.query<{ id: number; plan: string; questions: string }>( + `select id, plan, questions from setup_draft + where coach_id = $1 and published_at is null and discarded_at is null and updated_at > $2 + order by id desc limit 1`, + [coachId, new Date(now.getTime() - DRAFT_TTL_MS).toISOString()], + ); + const row = result.rows[0]; + if (!row) return null; + return { id: row.id, plan: JSON.parse(row.plan) as SetupPlan, questions: JSON.parse(row.questions) as string[] }; +} + +async function saveDraft( + db: DbClient, + coachId: number, + existingDraftId: number | null, + text: string, + plan: SetupPlan, + questions: string[], + now: Date, +): Promise { + if (existingDraftId !== null) { + await db.query( + `update setup_draft set source_text = source_text || E'\\n' || $2, plan = $3, questions = $4, updated_at = $5 + where id = $1`, + [existingDraftId, text, JSON.stringify(plan), JSON.stringify(questions), now.toISOString()], + ); + return existingDraftId; + } + const result = await db.query<{ id: number }>( + `insert into setup_draft (coach_id, source_text, plan, questions, created_at, updated_at) + values ($1, $2, $3, $4, $5, $5) returning id`, + [coachId, text, JSON.stringify(plan), JSON.stringify(questions), now.toISOString()], + ); + return result.rows[0].id; +} + +export type SetupResult = { kind: 'draft'; draft: SetupDraft } | { kind: 'cap' } | { kind: 'error' }; + +/** One message from the coach, on the web or by SMS: extract, validate, + * and save (or update) the open draft. Writes nothing to the schedule. */ +export async function handleSetupMessage( + db: DbClient, + coach: CoachRow, + text: string, + channel: 'sms' | 'web', + now: Date = new Date(), +): Promise { + const existing = await loadExistingTypes(db, coach.id); + const open = await getOpenDraft(db, coach.id, now); + const extracted = await extractSetupPlan(db, coach, text, existing, open, now, channel); + if (extracted.kind !== 'ok') return extracted; + const { plan, questions } = validateSetupPlan(extracted.raw, existing); + const id = await saveDraft(db, coach.id, open?.id ?? null, text, plan, questions, now); + return { kind: 'draft', draft: { id, plan, questions } }; +} + +export async function discardDraft(db: DbClient, coachId: number, draftId: number): Promise { + await db.query( + 'update setup_draft set discarded_at = now() where id = $1 and coach_id = $2 and published_at is null', + [draftId, coachId], + ); +} + +/** True once the coach has something on the calendar or a weekly time. */ +export async function coachHasSchedule(db: DbClient, coachId: number, now: Date = new Date()): Promise { + const result = await db.query<{ id: number }>( + `select s.id from session s join session_type st on st.id = s.session_type_id + where st.coach_id = $1 and s.status = 'scheduled' and s.starts_at_utc > $2 + union all + select ws.id from weekly_slot ws join session_type st on st.id = ws.session_type_id + where st.coach_id = $1 and ws.active = true + limit 1`, + [coachId, now.toISOString()], + ); + return result.rows.length > 0; +} + +export interface PublishCounts { + types: number; + weekly: number; + sessions: number; + packages: number; + plans: number; +} + +/** Writes a question-free draft to the schedule. There is no transaction + * (the pg Pool does not pin a client), so this is idempotent instead: the + * draft is claimed first, so a double tap publishes once; types, packages, + * plans, and weekly times that already exist are reused, not duplicated. */ +export async function publishSetupPlan( + db: DbClient, + coach: CoachRow, + draftId: number, + now: Date = new Date(), +): Promise { + const claimed = await db.query<{ plan: string }>( + `update setup_draft set published_at = $3 + where id = $1 and coach_id = $2 and published_at is null and discarded_at is null and questions = '[]' + returning plan`, + [draftId, coach.id, now.toISOString()], + ); + if (claimed.rows.length === 0) return null; + const plan = JSON.parse(claimed.rows[0].plan) as SetupPlan; + const counts: PublishCounts = { types: 0, weekly: 0, sessions: 0, packages: 0, plans: 0 }; + + let tz = coach.tz; + if (plan.coach_timezone && plan.coach_timezone !== coach.tz && !(await coachHasSchedule(db, coach.id, now))) { + tz = plan.coach_timezone; + await db.query('update coach set tz = $1 where id = $2', [tz, coach.id]); + } + + const existing = await loadExistingTypes(db, coach.id); + const typeIds = new Map(); + for (const type of plan.session_types) { + const match = existing.find((e) => e.id === type.existing_id || e.name.toLowerCase() === type.name.toLowerCase()); + if (match) { + typeIds.set(type.key, match.id); + continue; + } + const inserted = await db.query<{ id: number }>( + `insert into session_type (coach_id, name, duration_min, capacity, price_cents, backup_pay_cents, active) + values ($1, $2, $3, $4, $5, $6, true) returning id`, + [coach.id, type.name, type.duration_min, type.capacity, type.price_cents, type.backup_pay_cents], + ); + typeIds.set(type.key, inserted.rows[0].id); + counts.types += 1; + } + + const byType = new Map(); + for (const slot of plan.weekly) { + const typeId = typeIds.get(slot.type_key); + if (typeId === undefined) continue; + const dup = await db.query<{ id: number }>( + 'select id from weekly_slot where session_type_id = $1 and weekday = $2 and time_local = $3 and active = true', + [typeId, slot.weekday, slot.time_local], + ); + if (dup.rows.length > 0) continue; + const list = byType.get(typeId) ?? []; + list.push({ weekday: slot.weekday, timeLocal: slot.time_local, locationText: slot.location }); + byType.set(typeId, list); + } + for (const [typeId, slots] of byType) { + const created = await createWeeklySlots(db, typeId, tz, slots, now); + counts.weekly += created.length; + const n = await db.query<{ n: string }>( + 'select count(*)::text as n from session where weekly_slot_id = any($1::int[])', + [created.map((c) => c.id)], + ); + counts.sessions += Number(n.rows[0]?.n ?? '0'); + } + + for (const pack of plan.packages) { + const dup = await db.query<{ id: number }>( + 'select id from package where coach_id = $1 and lower(name) = lower($2) and active = true', + [coach.id, pack.name], + ); + if (dup.rows.length > 0) continue; + await createPackage(db, coach.id, pack.name, pack.credits, pack.price_cents, pack.expires_days ?? undefined); + counts.packages += 1; + } + for (const offer of plan.plans) { + const dup = await db.query<{ id: number }>( + 'select id from plan where coach_id = $1 and lower(name) = lower($2) and active = true', + [coach.id, offer.name], + ); + if (dup.rows.length > 0) continue; + await createPlan(db, coach.id, offer.name, offer.price_cents, offer.credits_per_month); + counts.plans += 1; + } + + await logAssistant(db, { + coachId: coach.id, + phone: coach.phone, + channel: 'web', + rawMessage: 'Publish', + layer: 'setup', + payload: counts, + outcome: 'published', + }); + return counts; +} + +// ---- The preview, written by product code ---- + +export function formatClock(time: string): string { + const [h, m] = time.split(':').map(Number); + return `${h % 12 || 12}:${String(m).padStart(2, '0')}${h >= 12 ? 'pm' : 'am'}`; +} + +function money(cents: number): string { + return cents % 100 === 0 ? `$${cents / 100}` : `$${(cents / 100).toFixed(2)}`; +} + +/** "Tue & Thu 6:00pm · Field 3", one line per time and place. */ +export function describeWeekly(slots: SetupSlot[]): string[] { + const groups = new Map(); + for (const slot of [...slots].sort((a, b) => a.time_local.localeCompare(b.time_local) || a.weekday - b.weekday)) { + const k = `${slot.time_local}|${slot.location ?? ''}`; + const g = groups.get(k) ?? { time: slot.time_local, location: slot.location, days: [] }; + g.days.push(slot.weekday); + groups.set(k, g); + } + return [...groups.values()].map((g) => { + const days = [...g.days].sort((a, b) => a - b).map((d) => WEEKDAYS[d]).join(' & '); + return `${days} ${formatClock(g.time)}${g.location ? ` · ${g.location}` : ''}`; + }); +} + +export function describeType(type: SetupType): string { + const parts = [`${type.duration_min} min`]; + parts.push(type.capacity === null ? 'how many?' : `${type.capacity} ${type.capacity === 1 ? 'athlete' : 'athletes'}`); + parts.push(type.price_cents === null ? 'price?' : money(type.price_cents)); + if (type.backup_pay_cents) parts.push(`backup pays ${money(type.backup_pay_cents)}`); + return parts.join(' · '); +} + +function firstSession(plan: SetupPlan, tz: string, now: Date): string | null { + let best: Date | null = null; + const today = zonedParts(now, tz); + for (const slot of plan.weekly) { + const [hour, minute] = slot.time_local.split(':').map(Number); + for (let offset = 0; offset <= 7; offset += 1) { + const d = addCalendarDays(today.year, today.month, today.day, offset); + if (new Date(Date.UTC(d.year, d.month - 1, d.day)).getUTCDay() !== slot.weekday) continue; + const at = zonedTimeToUtc(d.year, d.month, d.day, hour, minute, tz); + if (at > now && (!best || at < best)) best = at; + } + } + return best ? formatLocal(best.toISOString(), tz) : null; +} + +function zoneName(tz: string, now: Date): string { + try { + const part = new Intl.DateTimeFormat('en-US', { timeZone: tz, timeZoneName: 'longGeneric' }) + .formatToParts(now) + .find((p) => p.type === 'timeZoneName'); + return part?.value ?? tz; + } catch { + return tz; + } +} + +export function renderSetupPreview(plan: SetupPlan, coach: CoachRow, now: Date = new Date()): SafeHtml { + const tz = plan.coach_timezone ?? coach.tz; + const first = firstSession(plan, tz, now); + return html`
+ ${plan.session_types.map((type) => { + const times = describeWeekly(plan.weekly.filter((w) => w.type_key === type.key)); + return html`
+ ${type.name}${type.existing_id ? html` (already set up)` : raw('')} +
${describeType(type)}
+ ${times.map((line) => html`
${line}
`)} +
`; + })} + ${plan.packages.map( + (p) => html`
${p.name}: ${p.credits} sessions for ${money(p.price_cents)}${p.expires_days ? `, use within ${p.expires_days} days` : ''}
`, + )} + ${plan.plans.map( + (p) => html`
${p.name}: ${money(p.price_cents)}/mo, ${p.credits_per_month} sessions
`, + )} + ${first ? html`

First session: ${first}. Times in ${zoneName(tz, now)}.

` : raw('')} +

Your link: ${APP_BASE_URL.replace(/^https?:\/\//, '')}/c/${coach.handle}

+
`; +} + +/** The one-segment SMS answer to a setup message. */ +export function setupSmsReply(result: SetupResult): string { + const link = `${APP_BASE_URL}/app/schedule`; + if (result.kind !== 'draft') return `I couldn't read that. Set it up here: ${link}`; + const { plan, questions } = result.draft; + if (questions.length > 0) return `One question: ${questions[0]} Reply here.`; + const n = plan.session_types.length; + const m = plan.weekly.length; + return `Got it: ${n} session ${n === 1 ? 'type' : 'types'}, ${m} ${m === 1 ? 'time' : 'times'} a week. Check it and tap Publish: ${link}`; +} 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/jobs/tick.ts b/src/jobs/tick.ts new file mode 100644 index 0000000..262f5f7 --- /dev/null +++ b/src/jobs/tick.ts @@ -0,0 +1,93 @@ +import type { DbClient } from '../db/client.js'; +import { advanceCascade, isOptedOut } from '../domain/cascade.js'; +import { topUpAllSlots, zonedParts } from '../domain/scheduling.js'; +import { clipSms, formatConfirmWhen } from '../lib/time.js'; +import { sendText } from '../domain/outbound.js'; + +/** No non-urgent SMS between 9pm and 8am local (SPEC.md §10). */ +const QUIET_END_HOUR = 8; +const QUIET_START_HOUR = 21; +const REMINDER_WINDOW_MS = 24 * 60 * 60 * 1000; +const REMINDER_FLOOR_MS = 2 * 60 * 60 * 1000; + +export function inQuietHours(now: Date, tz: string): boolean { + const hour = zonedParts(now, tz).hour; + return hour < QUIET_END_HOUR || hour >= QUIET_START_HOUR; +} + +/** Texts each booked athlete once, inside 24h of the session and outside + * quiet hours. A session too early in the morning for the 24h mark is + * reminded at 8am the day before instead. */ +export async function sendDueReminders(db: DbClient, now: Date): Promise { + const due = await db.query<{ + booking_id: number; + athlete_name: string; + contact_phone: string; + starts_at_utc: string; + tz: string; + location_text: string | null; + name: string; + coach_id: number; + coach_tz: string; + }>( + `select b.id as booking_id, b.athlete_name, b.contact_phone, s.starts_at_utc, s.tz, s.location_text, + st.name, c.id as coach_id, c.tz as coach_tz + from booking b + join session s on s.id = b.session_id + join session_type st on st.id = s.session_type_id + join coach c on c.id = st.coach_id + where b.status = 'booked' and s.status = 'scheduled' + and s.starts_at_utc > $1 and s.starts_at_utc <= $2 + and not exists (select 1 from reminder_sent r where r.booking_id = b.id) + order by s.starts_at_utc`, + [new Date(now.getTime() + REMINDER_FLOOR_MS).toISOString(), new Date(now.getTime() + REMINDER_WINDOW_MS).toISOString()], + ); + + let sent = 0; + for (const row of due.rows) { + if (inQuietHours(now, row.coach_tz)) continue; + const claimed = await db.query<{ booking_id: number }>( + 'insert into reminder_sent (booking_id) values ($1) on conflict (booking_id) do nothing returning booking_id', + [row.booking_id], + ); + if (claimed.rows.length === 0) continue; + if (await isOptedOut(db, row.contact_phone)) continue; + const when = formatConfirmWhen(new Date(row.starts_at_utc).toISOString(), row.tz); + const place = row.location_text ? ` at ${row.location_text}` : ''; + const outcome = await sendText( + db, + { + to: row.contact_phone, + body: clipSms(`Coachatron: ${row.athlete_name}'s ${row.name} is ${when}${place}.`), + coachId: row.coach_id, + template: 'reminder', + }, + now, + ); + if (outcome === 'sent') sent += 1; + } + return sent; +} + +type TickStep = (db: DbClient, now: Date) => Promise; + +const STEPS: Array<[string, TickStep]> = [ + ['top up weekly slots', topUpAllSlots], + ['advance overflow cascades', advanceCascade], + ['send reminders', sendDueReminders], +]; + +/** The scheduled job (SPEC.md §11: "a scheduled job over a table"). Each + * step catches its own failure so one broken step never starves the rest. */ +export async function runTick(db: DbClient, now: Date, steps: Array<[string, TickStep]> = STEPS): Promise { + const failed: string[] = []; + for (const [name, step] of steps) { + try { + await step(db, now); + } catch (err) { + failed.push(name); + console.error(`tick: ${name} failed:`, err); + } + } + return failed; +} diff --git a/src/lib/html.ts b/src/lib/html.ts index b63ca96..9964b67 100644 --- a/src/lib/html.ts +++ b/src/lib/html.ts @@ -113,7 +113,7 @@ export const POSTCARD_TOKENS_CSS = ` h2 { font-size: 1.1rem; margin: 18px 0 8px; font-weight: 600; } .muted, p.muted { color: var(--screen-muted); font-family: ui-sans-serif, system-ui, sans-serif; font-size: 0.95rem; } label { display: block; font-family: ui-sans-serif, system-ui, sans-serif; font-size: 0.85rem; color: var(--screen-muted); margin: 14px 0 4px; } - input, select { + input, select, textarea { width: 100%; max-width: 100%; min-height: var(--touch-min); @@ -219,6 +219,22 @@ export const POSTCARD_TOKENS_CSS = ` .btn-row { display: flex; gap: 8px; } .btn-row button { margin-top: 8px; flex: 1; } button.ghost { background: transparent; color: var(--teal); border: 1px solid var(--teal); } + a.card { display: block; color: inherit; text-decoration: none; } + textarea { min-height: 9rem; resize: vertical; line-height: 1.4; } + .setup-type { padding: 8px 0; border-bottom: 1px solid var(--line); } + .setup-type:last-of-type { border-bottom: 0; } + .setup-questions { margin: 8px 0 0; padding-left: 1.2rem; font-family: ui-sans-serif, system-ui, sans-serif; } + a.action.ghost, .action.ghost { background: transparent; color: var(--teal); border: 1px solid var(--teal); } + .coach-nav { + display: flex; + flex-wrap: wrap; + gap: 4px 14px; + margin: 0 0 14px; + font-family: ui-sans-serif, system-ui, sans-serif; + font-size: 0.9rem; + } + .coach-nav a { color: var(--screen-muted); text-decoration: none; padding: 6px 0; } + .coach-nav a[aria-current="page"] { color: var(--teal); font-weight: 600; } @media (min-width: 768px) { :root { --shell-max: min(640px, 100%); } body { @@ -247,6 +263,26 @@ export const POSTCARD_TOKENS_CSS = ` } `; +export type CoachNavKey = 'schedule' | 'types' | 'pricing' | 'roster' | 'money'; + +const COACH_NAV: Array<[CoachNavKey, string, string]> = [ + ['schedule', '/app/schedule', 'Schedule'], + ['types', '/app/session-types', 'Types'], + ['pricing', '/app/pricing', 'Pricing'], + ['roster', '/app/roster', 'Roster'], + ['money', '/app/money', 'Money'], +]; + +/** Plain links across the coach screens. Muted text, not buttons, so each + * screen keeps its one teal action. */ +export function coachNav(active?: CoachNavKey): SafeHtml { + return html``; +} + export function page(title: string, body: SafeHtml): string { return ` @@ -261,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/lib/time.ts b/src/lib/time.ts index 43beccc..09c5be5 100644 --- a/src/lib/time.ts +++ b/src/lib/time.ts @@ -43,3 +43,16 @@ export function clipSms(text: string, max = 160): string { if (t.length <= max) return t; return `${t.slice(0, max - 1)}…`; } + +export const DEFAULT_TZ = 'America/Chicago'; + +/** True for an IANA zone Intl accepts, e.g. "America/Denver". */ +export function isValidTimeZone(tz: string): boolean { + if (!tz || !tz.includes('/')) return false; + try { + new Intl.DateTimeFormat('en-US', { timeZone: tz }); + return true; + } catch { + return false; + } +} diff --git a/src/llm/complete.ts b/src/llm/complete.ts index d136a6d..870ecca 100644 --- a/src/llm/complete.ts +++ b/src/llm/complete.ts @@ -11,7 +11,7 @@ async function postOnce(req: CompleteRequest): Promise { throw new Error('LITELLM_API_KEY missing'); } const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), LITELLM_TIMEOUT_MS); + const timer = setTimeout(() => controller.abort(), req.timeoutMs ?? LITELLM_TIMEOUT_MS); try { const res = await fetch(`${base}/chat/completions`, { method: 'POST', @@ -25,7 +25,7 @@ async function postOnce(req: CompleteRequest): Promise { messages: req.messages, response_format: { type: 'json_schema', - json_schema: { name: 'intent', schema: req.schema, strict: true }, + json_schema: { name: req.schemaName ?? 'intent', schema: req.schema, strict: true }, }, }), signal: controller.signal, diff --git a/src/llm/schema.ts b/src/llm/schema.ts index d51ea1d..7c37875 100644 --- a/src/llm/schema.ts +++ b/src/llm/schema.ts @@ -45,6 +45,10 @@ export interface ChatMessage { export interface CompleteRequest { messages: ChatMessage[]; schema: Record; + /** Schema name sent to the relay. Defaults to "intent". */ + schemaName?: string; + /** Per-attempt timeout. Defaults to LITELLM_TIMEOUT_MS (the SMS path). */ + timeoutMs?: number; } export type CompleteFn = (req: CompleteRequest) => Promise; diff --git a/src/llm/setupSchema.ts b/src/llm/setupSchema.ts new file mode 100644 index 0000000..1c63ddb --- /dev/null +++ b/src/llm/setupSchema.ts @@ -0,0 +1,52 @@ +/** The second closed schema the model fills (PLATFORM.md §4.2): a coach's + * spoken or typed week, extracted into rows. The model only extracts; + * src/domain/setup.ts validates, asks, previews, and writes. Strict: every + * property required, nullable where the coach may not have said it. */ + +function obj(properties: Record) { + return { type: 'object', additionalProperties: false, properties, required: Object.keys(properties) }; +} + +const nullableInt = { type: ['integer', 'null'] }; +const nullableStr = { type: ['string', 'null'] }; + +export const SETUP_JSON_SCHEMA = obj({ + coach_timezone: nullableStr, + session_types: { + type: 'array', + items: obj({ + key: { type: 'string' }, + name: { type: 'string' }, + duration_min: nullableInt, + capacity: nullableInt, + price_cents: nullableInt, + backup_pay_cents: nullableInt, + }), + }, + weekly: { + type: 'array', + items: obj({ + type_key: { type: 'string' }, + weekday: { type: 'integer' }, + time_local: { type: 'string' }, + location: nullableStr, + }), + }, + packages: { + type: 'array', + items: obj({ + name: { type: 'string' }, + credits: { type: 'integer' }, + price_cents: { type: 'integer' }, + expires_days: nullableInt, + }), + }, + plans: { + type: 'array', + items: obj({ + name: { type: 'string' }, + price_cents: { type: 'integer' }, + credits_per_month: { type: 'integer' }, + }), + }, +}); diff --git a/src/routes/coach.ts b/src/routes/coach.ts index 771f0d1..a6aba42 100644 --- a/src/routes/coach.ts +++ b/src/routes/coach.ts @@ -1,6 +1,6 @@ import { Router, type Request, type Response, type NextFunction } from 'express'; import { getDb, type DbClient } from '../db/client.js'; -import { sendSms } from '../relay/sms.js'; +import { sendText } from '../domain/outbound.js'; import { normalizePhone, createOtp, @@ -14,15 +14,25 @@ import { getConnectRecipientKey, type CoachRow, } from '../domain/auth.js'; -import { APP_BASE_URL, APP_FEE_BPS, SELLER_AGREEMENT_VERSION } from '../config.js'; +import { APP_BASE_URL, APP_FEE_BPS, OTP_DAILY_MAX_PER_PHONE, SELLER_AGREEMENT_VERSION } from '../config.js'; import { acceptSellerAgreement, getSellerStatus, startSellerOnboarding } from '../relay/seller.js'; -import { generateWeekSessions, type WeeklySlot } from '../domain/scheduling.js'; -import { html, page, raw } from '../lib/html.js'; -import { formatLocal } from '../lib/time.js'; +import { createWeeklySlots, deactivateSlot, listWeeklySlots, type WeeklySlot } from '../domain/scheduling.js'; +import { coachNav, html, page, raw } from '../lib/html.js'; +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, + getOpenDraft, + handleSetupMessage, + publishSetupPlan, + renderSetupPreview, + type SetupDraft, +} from '../domain/setup.js'; // ---- Roster functions (M4 overflow cascade) ---- @@ -83,42 +93,55 @@ async function requireAuth(req: Request, res: Response, next: NextFunction): Pro // ---- Screen 1: sign in ---- -coachRouter.get('/signin', (_req, res) => { - res.status(200).send( - page( - 'Sign in', - html`

Coachatron

-

Enter your phone number to sign in or sign up.

-
- - - -
`, - ), +function renderPhoneForm(value = '', error?: string, saved = false) { + return page( + 'Sign in', + html`

Coachatron

+

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

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

${error}

` : raw('')}`, ); +} + +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( - page( - 'Sign in', - html`

Coachatron

-
- - - -
-

Enter a valid phone number.

`, - ), - ); + res.status(422).send(renderPhoneForm(raw_phone, 'Enter a valid phone number.', saved)); return; } const db = getDb(); const code = await createOtp(db, phone); - await sendSms({ to: phone, body: `Coachatron: your sign-in code is ${code}. Expires in 10 minutes.` }); + const outcome = await sendText(db, { + to: phone, + body: `Coachatron: your sign-in code is ${code}. Expires in 10 minutes.`, + coachId: null, + template: 'otp', + perRecipientDailyMax: OTP_DAILY_MAX_PER_PHONE, + }); + if (outcome !== 'sent') { + res + .status(outcome === 'refused' ? 422 : 429) + .send( + renderPhoneForm( + raw_phone, + outcome === 'refused' + ? 'Use a US or Canadian mobile number.' + : 'Too many codes for this number today. Try again tomorrow.', + saved, + ), + ); + return; + } res.redirect(303, `/signin/verify?phone=${encodeURIComponent(phone)}`); }); @@ -127,6 +150,12 @@ coachRouter.get('/signin/verify', (req, res) => { res.status(200).send(renderVerifyForm(phone, {})); }); +// The browser knows the coach's timezone; a typed IANA name was a +// five-minute-setup tax. The server still validates it and falls back. +const TZ_SCRIPT = raw( + ``, +); + function renderVerifyForm( phone: string, values: { code?: string; name?: string; email?: string; tz?: string }, @@ -139,19 +168,19 @@ function renderVerifyForm(
- + -

First time here? Tell us about your business.

+

New here? Just your name. Everything else can wait.

- - - - - + + + +
- ${error ? raw(`

${error}

`) : raw('')}`, + ${error ? raw(`

${error}

`) : raw('')} + ${TZ_SCRIPT}`, ); } @@ -171,22 +200,30 @@ coachRouter.post('/signin/verify', async (req, res) => { let coach: CoachRow | null = await findCoachByPhone(db, phone); if (!coach) { - if (!name || !email || !tz || !tz.includes('/')) { - res - .status(422) - .send( - renderVerifyForm(phone, { code, name, email, tz }, 'Enter your name, email, and a timezone like America/Chicago.'), - ); + if (!name) { + res.status(422).send(renderVerifyForm(phone, { code, name, email, tz }, 'Enter your name.')); return; } await consumeOtp(db, otpId); - coach = await createCoach(db, { phone, name, email, tz }); + coach = await createCoach(db, { phone, name, email, tz: isValidTimeZone(tz) ? tz : DEFAULT_TZ }); } else { await consumeOtp(db, otpId); } 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'); }); @@ -195,20 +232,32 @@ coachRouter.post('/signin/verify', async (req, res) => { coachRouter.get('/app/session-types', requireAuth, async (_req, res) => { const db = getDb(); const coachId = res.locals.coachId as number; - const types = await db.query<{ id: number; name: string; duration_min: number; capacity: number; price_cents: number }>( - 'select id, name, duration_min, capacity, price_cents from session_type where coach_id = $1 order by id', + const types = await db.query<{ + id: number; + name: string; + duration_min: number; + capacity: number; + price_cents: number; + backup_pay_cents: number | null; + }>( + 'select id, name, duration_min, capacity, price_cents, backup_pay_cents from session_type where coach_id = $1 order by id', [coachId], ); res.status(200).send( page( 'Session types', - html`

Session types

+ html`${coachNav('types')} +

Session types

${types.rows.map( (t) => - html`
- ${t.name} — ${t.duration_min} min, capacity ${t.capacity}, $${(t.price_cents / 100).toFixed(2)} - -
`, + html` + ${t.name} +
+ ${t.duration_min} min · ${t.capacity} athletes · $${(t.price_cents / 100).toFixed(2)} + Weekly times › +
+ ${t.backup_pay_cents ? html`
Backup coach pays $${(t.backup_pay_cents / 100).toFixed(2)}
` : raw('')} +
`, )}
@@ -217,8 +266,10 @@ coachRouter.get('/app/session-types', requireAuth, async (_req, res) => { - + + +
`, ), @@ -232,8 +283,19 @@ coachRouter.post('/app/session-types', requireAuth, async (req, res) => { const capacity = Number(field(req.body, 'capacity')); const priceDollars = Number(field(req.body, 'price_dollars')); const priceCents = Math.round(priceDollars * 100); - - if (!name || !Number.isInteger(durationMin) || durationMin <= 0 || !Number.isInteger(capacity) || capacity <= 0 || !Number.isFinite(priceCents) || priceCents <= 0) { + const backupPayRaw = field(req.body, 'backup_pay_dollars'); + const backupPayCents = backupPayRaw ? Math.round(Number(backupPayRaw) * 100) : null; + + if ( + !name || + !Number.isInteger(durationMin) || + durationMin <= 0 || + !Number.isInteger(capacity) || + capacity <= 0 || + !Number.isFinite(priceCents) || + priceCents <= 0 || + (backupPayCents !== null && (!Number.isFinite(backupPayCents) || backupPayCents < 0)) + ) { res.status(422).send( page( 'Session types', @@ -247,8 +309,9 @@ coachRouter.post('/app/session-types', requireAuth, async (req, res) => { const db = getDb(); await db.query( - 'insert into session_type (coach_id, name, duration_min, capacity, price_cents, active) values ($1, $2, $3, $4, $5, true)', - [coachId, name, durationMin, capacity, priceCents], + `insert into session_type (coach_id, name, duration_min, capacity, price_cents, backup_pay_cents, active) + values ($1, $2, $3, $4, $5, $6, true)`, + [coachId, name, durationMin, capacity, priceCents, backupPayCents || null], ); res.redirect(303, '/app/session-types'); }); @@ -257,84 +320,204 @@ coachRouter.get('/app/session-types/:id/generate-week', requireAuth, async (req, const db = getDb(); const coachId = res.locals.coachId as number; const typeId = Number(req.params.id); - const typeRows = await db.query<{ id: number }>('select id from session_type where id = $1 and coach_id = $2', [ - typeId, - coachId, - ]); + const typeRows = await db.query<{ id: number; name: string }>( + 'select id, name from session_type where id = $1 and coach_id = $2', + [typeId, coachId], + ); if (typeRows.rows.length === 0) { res.status(404).send(page('Not found', html`

Not found

`)); return; } - res.status(200).send( - page( - 'Generate this week', - html`

Weekly times

-

Check the days this session runs and set a start time for each.

-
- ${WEEKDAY_LABELS.map( - (label, i) => - html``, - )} - -
`, - ), - ); + const kept = typeof req.query.kept === 'string' ? Number(req.query.kept) : 0; + res.status(200).send(renderWeeklyTimes(typeRows.rows[0], await listWeeklySlots(db, typeId), kept)); }); +function renderWeeklyTimes( + type: { id: number; name: string }, + slots: Array<{ id: number; weekday: number; time_local: string; location_text: string | null }>, + kept: number, + error?: string, +) { + return page( + 'Weekly times', + html`${coachNav('types')} +

${type.name}

+

Weekly times. Sessions stay on your calendar 8 weeks ahead.

+ ${kept > 0 + ? html`

${kept} upcoming ${kept === 1 ? 'session has' : 'sessions have'} bookings and stayed. Cancel ${kept === 1 ? 'it' : 'them'} from the schedule if you need to.

` + : raw('')} + ${slots.map( + (slot) => html`
+
+ ${WEEKDAY_LABELS[slot.weekday]} ${slot.time_local}${slot.location_text ? ` · ${slot.location_text}` : ''} +
+ +
+
+
`, + )} +
+ ${WEEKDAY_LABELS.map( + (label, i) => + html``, + )} + + + +
+ ${error ? html`

${error}

` : raw('')}`, + ); +} + coachRouter.post('/app/session-types/:id/generate-week', requireAuth, async (req, res) => { const db = getDb(); const coachId = res.locals.coachId as number; const typeId = Number(req.params.id); - const typeRows = await db.query<{ - id: number; - coach_id: number; - name: string; - duration_min: number; - capacity: number; - price_cents: number; - }>('select id, coach_id, name, duration_min, capacity, price_cents from session_type where id = $1 and coach_id = $2', [ - typeId, - coachId, - ]); + const typeRows = await db.query<{ id: number; name: string }>( + 'select id, name from session_type where id = $1 and coach_id = $2', + [typeId, coachId], + ); if (typeRows.rows.length === 0) { res.status(404).send(page('Not found', html`

Not found

`)); return; } const coachRows = await db.query<{ tz: string }>('select tz from coach where id = $1', [coachId]); - const tz = coachRows.rows[0]?.tz ?? 'America/Chicago'; + const tz = coachRows.rows[0]?.tz ?? DEFAULT_TZ; const body = req.body as Record; + const locationText = field(body, 'location') || null; const slots: WeeklySlot[] = []; for (let i = 0; i < 7; i += 1) { const checked = field(body, `day_${i}`) === '1'; const timeLocal = field(body, `time_${i}`); if (checked && /^\d{2}:\d{2}$/.test(timeLocal)) { - slots.push({ weekday: i, timeLocal }); + slots.push({ weekday: i, timeLocal, locationText }); } } if (slots.length === 0) { - res.status(422).send( - page( - 'Weekly times', - html`

Weekly times

-

Pick at least one day and time.

`, - ), - ); + res.status(422).send(renderWeeklyTimes(typeRows.rows[0], await listWeeklySlots(db, typeId), 0, 'Pick at least one day and time.')); return; } - await generateWeekSessions(db, typeRows.rows[0], tz, slots, new Date()); + await createWeeklySlots(db, typeId, tz, slots, new Date()); res.redirect(303, '/app/schedule'); }); +coachRouter.post('/app/session-types/:id/slots/:slotId/remove', requireAuth, async (req, res) => { + const db = getDb(); + const coachId = res.locals.coachId as number; + const typeId = Number(req.params.id); + const kept = await deactivateSlot(db, coachId, Number(req.params.slotId), new Date()); + if (kept === null) { + res.status(404).send(page('Not found', html`

Not found

`)); + return; + } + res.redirect(303, `/app/session-types/${typeId}/generate-week${kept > 0 ? `?kept=${kept}` : ''}`); +}); + // ---- Screen 2: schedule ---- -coachRouter.get('/app/schedule', requireAuth, async (_req, res) => { +// Voice setup lives on the schedule screen (screen 2), not a thirteenth +// screen: a coach with nothing on the calendar sees "Tell me your week". + +const SETUP_NOTICES: Record = { + read: "I couldn't read that. Try again, or set it up by hand.", + cap: "That's all the reading I can do today. Set it up by hand for now.", + stale: 'That setup already went live or was cleared.', +}; + +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, + 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.'; + const submit = !draft ? 'Set it up' : questions.length > 0 ? 'Answer' : 'Update'; + return page( + 'Set up', + html`${coachNav('schedule')} +

${draft ? "Here's your week" : 'Tell me your week'}

+ ${notice ? html`

${notice}

` : raw('')} + ${draft + ? renderSetupPreview(draft.plan, coach) + : html`

Tap the mic on your keyboard and talk. Say what you coach, how long, how many athletes, the price, and your days and times.

`} + ${questions.length > 0 + ? html`
    ${questions.map((q) => html`
  • ${q}
  • `)}
` + : raw('')} + ${ready + ? html`
+ + +
` + : raw('')} +
+ + + +
+ ${draft + ? html`
+ + +
` + : raw('')} +

+ ${hasSchedule + ? html`Back to the schedule` + : html`Rather use forms? Set it up by hand.`} +

`, + ); +} + +async function paymentsConnected(coach: CoachRow): Promise { + try { + return (await getSellerStatus(getConnectRecipientKey(coach)))?.chargesEnabled === true; + } catch { + return null; + } +} + +function renderLiveCard(coach: CoachRow, connected: boolean | null) { + const link = `${process.env.APP_BASE_URL ?? APP_BASE_URL}/c/${coach.handle}`; + const share = `sms:?&body=${encodeURIComponent(`Book with me: ${link}`)}`; + return html`
+

You're live. Parents book at ${link.replace(/^https?:\/\//, '')}

+ ${connected === false + ? html`

They can't pay you until payments are connected.

+ Connect payments + Share your link` + : html`Share your link`} +
`; +} + +coachRouter.get('/app/schedule', requireAuth, async (req, res) => { const db = getDb(); const coachId = res.locals.coachId as number; + const coach = await findCoachById(db, coachId); + if (!coach) { + res.redirect(303, '/signin'); + return; + } + const hasSchedule = await coachHasSchedule(db, coachId); + 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; + 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(''); const sessions = await db.query<{ id: number; starts_at_utc: string; @@ -342,8 +525,10 @@ coachRouter.get('/app/schedule', requireAuth, async (_req, res) => { name: string; capacity: number; price_cents: number; + booked: string; }>( - `select s.id, s.starts_at_utc, s.tz, st.name, st.capacity, st.price_cents + `select s.id, s.starts_at_utc, s.tz, st.name, coalesce(s.capacity_override, st.capacity) as capacity, st.price_cents, + coalesce((select count(*) from booking b where b.session_id = s.id and b.status = 'booked'), 0)::text as booked from session s join session_type st on st.id = s.session_type_id where st.coach_id = $1 and s.status = 'scheduled' and s.starts_at_utc > now() @@ -358,7 +543,10 @@ coachRouter.get('/app/schedule', requireAuth, async (_req, res) => { page( 'Schedule', html`
-

This week

+ ${coachNav('schedule')} +

Coming up

+ ${live} +

Add more by talking

${assistant.reply ? html`

${assistant.reply.body}

@@ -372,15 +560,15 @@ coachRouter.get('/app/schedule', requireAuth, async (_req, res) => {
` : raw('')} ${sessions.rows.length === 0 - ? html`

No sessions yet. Create a session type and generate a week.

` + ? html`

No sessions yet. Add a session type and its weekly times.

+ Session types` : sessions.rows.map( (s) => - html``, +
${s.name}${s.booked}/${s.capacity} · $${(s.price_cents / 100).toFixed(2)}
+
`, )} - Session types
@@ -393,6 +581,41 @@ coachRouter.get('/app/schedule', requireAuth, async (_req, res) => { ); }); +coachRouter.post('/app/setup', requireAuth, async (req, res) => { + const db = getDb(); + const coach = await findCoachById(db, res.locals.coachId as number); + if (!coach) { + res.redirect(303, '/signin'); + return; + } + const text = field(req.body, 'text'); + if (!text) { + res.redirect(303, '/app/schedule?setup=1'); + return; + } + const result = await handleSetupMessage(db, coach, text, 'web'); + const error = result.kind === 'cap' ? '&e=cap' : result.kind === 'error' ? '&e=read' : ''; + res.redirect(303, `/app/schedule?setup=1${error}`); +}); + +coachRouter.post('/app/setup/publish', requireAuth, async (req, res) => { + const db = getDb(); + const coach = await findCoachById(db, res.locals.coachId as number); + if (!coach) { + res.redirect(303, '/signin'); + return; + } + const counts = await publishSetupPlan(db, coach, Number(field(req.body, 'id'))); + res.redirect(303, counts ? '/app/schedule?published=1' : '/app/schedule?setup=1&e=stale'); +}); + +coachRouter.post('/app/setup/discard', requireAuth, async (req, res) => { + const db = getDb(); + const coachId = res.locals.coachId as number; + await discardDraft(db, coachId, Number(field(req.body, 'id'))); + res.redirect(303, '/app/schedule?setup=1'); +}); + coachRouter.post('/app/schedule/ask', requireAuth, async (req, res) => { const db = getDb(); const coachId = res.locals.coachId as number; @@ -470,7 +693,8 @@ coachRouter.get('/app/sessions/:id', requireAuth, async (req, res) => { res.status(200).send( page( session.name, - html`

${session.name}

+ html`${coachNav('schedule')} +

${session.name}

${formatLocal(session.starts_at_utc, session.tz)}

Roster (${bookings.rows.length} attendees)

@@ -567,9 +791,11 @@ coachRouter.post('/app/sessions/:id/cancel', requireAuth, async (req, res) => { ); for (const athlete of cancelled.rows) { - await sendSms({ + await sendText(db, { to: athlete.contact_phone, body: `${athlete.athlete_name}'s ${session.name} on ${formatLocal(session.starts_at_utc, session.tz)} has been cancelled.`, + coachId, + template: 'session-cancelled', }); } @@ -589,7 +815,8 @@ coachRouter.get('/app/pricing', requireAuth, async (_req, res) => { res.status(200).send( page( 'Pricing', - html`

Pricing & credits

+ html`${coachNav('pricing')} +

Pricing & credits

Session packages

${packagesList.length === 0 ? html`

No packages yet.

` @@ -696,7 +923,8 @@ coachRouter.get('/app/roster', requireAuth, async (_req, res) => { res.status(200).send( page( 'Roster', - html`

Overflow roster

+ html`${coachNav('roster')} +

Overflow roster

${members.length === 0 ? html`

No roster members yet.

` : html`
    @@ -840,7 +1068,8 @@ async function renderMoney(res: Response, coach: CoachRow, opts: { status?: numb res.status(opts.status ?? 200).send( page( 'Money', - html`

    Money

    + html`${coachNav('money')} +

    Money

    ${renderPaymentsCard(payments, opts.notice ?? null, opts.error ?? null)}

    This week

    ${formatDollars(summary.collectedThisWeekCents)}

    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/public.ts b/src/routes/public.ts index e93c770..7b8eb2c 100644 --- a/src/routes/public.ts +++ b/src/routes/public.ts @@ -15,7 +15,7 @@ import { markBookingBooked, countBookedForSession, } from '../domain/pricing.js'; -import { checkOverflow, acceptOffer, declineOffer, getOfferByToken } from '../domain/cascade.js'; +import { checkOverflow, acceptOffer, declineOffer, describeOffer, getOfferByToken } from '../domain/cascade.js'; import { buyerEmail, connectBuyUrl } from '../relay/buy-link.js'; export const publicRouter = Router(); @@ -475,21 +475,22 @@ publicRouter.get('/offer/:token', async (req, res) => { res.status(404).send(notFound()); return; } - res.status(200).send(renderOfferPage(offer.state)); + res.status(200).send(renderOfferPage(offer.state, await describeOffer(db, offer.sessionId))); }); -function renderOfferPage(state: string, message?: string) { +function renderOfferPage(state: string, summary: string | null, message?: string) { const resolved = state !== 'sent'; return page( 'Overflow offer', html`

    Backup coach needed

    + ${summary ? html`

    ${summary}

    ` : raw('')} ${resolved ? html`

    This offer is no longer open (${state}).

    ` : html`
    - +
    `} ${message ? html`

    ${message}

    ` : raw('')}`, ); @@ -504,8 +505,9 @@ publicRouter.post('/offer/:token', async (req, res) => { } const action = field(req.body, 'action'); + const summary = await describeOffer(db, offer.sessionId); if (offer.state !== 'sent') { - res.status(200).send(renderOfferPage(offer.state)); + res.status(200).send(renderOfferPage(offer.state, summary)); return; } @@ -513,15 +515,15 @@ publicRouter.post('/offer/:token', async (req, res) => { const accepted = await acceptOffer(db, offer.id); res .status(200) - .send(renderOfferPage(accepted ? 'accepted' : offer.state, accepted ? undefined : 'Too late — someone else already claimed it.')); + .send(renderOfferPage(accepted ? 'accepted' : offer.state, summary, accepted ? undefined : 'Too late — someone else already claimed it.')); return; } if (action === 'decline') { await declineOffer(db, offer.id); - res.status(200).send(renderOfferPage('declined')); + res.status(200).send(renderOfferPage('declined', summary)); return; } - res.status(422).send(renderOfferPage(offer.state, 'Choose accept or decline.')); + res.status(422).send(renderOfferPage(offer.state, summary, 'Choose accept or decline.')); }); // ---- Screen 11: manage booking (M5 self-service) ---- diff --git a/src/routes/webhooks.ts b/src/routes/webhooks.ts index 920ee4c..9df0528 100644 --- a/src/routes/webhooks.ts +++ b/src/routes/webhooks.ts @@ -1,6 +1,6 @@ import { Router } from 'express'; import { getDb, type DbClient } from '../db/client.js'; -import { sendSms } from '../relay/sms.js'; +import { sendText } from '../domain/outbound.js'; import { findCoachByPhone, normalizePhone } from '../domain/auth.js'; import { acceptOffer, @@ -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'; @@ -41,7 +42,7 @@ async function findSentOfferForPhone( async function replyOnce(db: DbClient, phone: string, body: string, now: Date): Promise { const ok = await takeDailyReplySlot(db, phone, now); if (!ok) return false; - await sendSms({ to: phone, body: clipSms(body) }); + await sendText(db, { to: phone, body: clipSms(body), coachId: null, template: 'auto-reply' }, now); return true; } @@ -64,9 +65,11 @@ webhooksRouter.post('/webhooks/sms', async (req, res) => { if (kw === 'STOP') { await upsertOptOut(db, from); - await sendSms({ + await sendText(db, { to: from, body: 'Coachatron: you are opted out. Text HELP for help, or use your booking link.', + coachId: null, + template: 'stop', }); await logAssistant(db, { phone: from, @@ -81,7 +84,12 @@ webhooksRouter.post('/webhooks/sms', async (req, res) => { } if (kw === 'HELP') { - await sendSms({ to: from, body: 'Coachatron: for help, visit your booking or coach link. Text STOP to opt out.' }); + await sendText(db, { + to: from, + body: 'Coachatron: for help, visit your booking or coach link. Text STOP to opt out.', + coachId: null, + template: 'help', + }); await logAssistant(db, { phone: from, channel: 'sms', @@ -132,11 +140,30 @@ webhooksRouter.post('/webhooks/sms', async (req, res) => { } const reply = await handleCoachMessage(db, coach, body, 'sms', now); - await sendSms({ to: from, body: reply.text }); + await sendText(db, { to: from, body: reply.text, coachId: coach.id, template: 'assistant' }, now); res.status(200).send(reply.kind); 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 bbf9bb3..f1cce10 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,11 +1,15 @@ 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'; 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'; + +const TICK_MS = 60_000; export function createApp() { const app = express(); @@ -13,10 +17,8 @@ export function createApp() { app.use(express.urlencoded({ extended: true })); app.use(express.json()); - app.get('/', (_req, res) => { - res.status(200).type('html').send('

    Coachatron

    '); - }); - + // GET / is also the Railway healthcheck, so it never touches the database. + app.use(landingRouter); app.use(coachRouter); app.use(publicRouter); app.use(webhooksRouter); @@ -46,7 +48,22 @@ if (import.meta.url === `file://${process.argv[1]}`) { console.error('Unhandled rejection (request may hang, but the process stays up):', reason); }); + // One replica on Railway. Running more would need pg_try_advisory_lock + // around runTick so two processes never tick at once. + let ticking = false; + const tick = async () => { + if (ticking) return; + ticking = true; + try { + await runTick(getDb(), new Date()); + } finally { + ticking = false; + } + }; + void tick(); + setInterval(() => void tick(), TICK_MS); + createApp().listen(PORT, () => { - console.log(`Coachatron listening on ${PORT}`); + console.log(`Coachatron listening on ${PORT} (Node ${process.version})`); }); } 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 f1e3f85..7e400a6 100644 --- a/test/coach-onboarding.test.ts +++ b/test/coach-onboarding.test.ts @@ -106,3 +106,61 @@ test('GET /c/does-not-exist returns 404', async () => { assert.equal(res.status, 404); }); }); + +async function signUp(base: string, relay: { sms: Array<{ to: string; body: string }> }, digits: string, profile: Record) { + await fetch(`${base}/signin/otp`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + 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' }, + body: JSON.stringify({ phone, code, ...profile }), + redirect: 'manual', + }); +} + +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); + const body = await res.text(); + assert.match(body, /action="\/start"/); + assert.match(body, /href="\/signin">Sign in { + await withRelay(async (relay) => { + const db = await freshDb(); + await withServer(async (base) => { + const form = await (await fetch(`${base}/signin/verify?phone=%2B15550001111`)).text(); + assert.match(form, /autocomplete="one-time-code"/); + assert.match(form, /name="tz" type="hidden"/); + assert.doesNotMatch(form, /IANA/); + + const denver = await signUp(base, relay, '5550001111', { name: 'Dana Keeper', tz: 'America/Denver' }); + assert.equal(denver.status, 303); + const bogus = await signUp(base, relay, '5550002222', { name: 'Robin Coach', tz: 'Mars/Olympus' }); + assert.equal(bogus.status, 303); + + const rows = await db.query<{ phone: string; tz: string; email: string }>('select phone, tz, email from coach order by id'); + assert.deepEqual( + rows.rows.map((r) => [r.phone, r.tz, r.email]), + [ + ['+15550001111', 'America/Denver', ''], + ['+15550002222', 'America/Chicago', ''], + ], + ); + + const noName = await signUp(base, relay, '5550003333', {}); + assert.equal(noName.status, 422); + assert.match(await noName.text(), /Enter your name/); + }); + }); +}); diff --git a/test/fakes/relay.ts b/test/fakes/relay.ts index 243607f..8be79e9 100644 --- a/test/fakes/relay.ts +++ b/test/fakes/relay.ts @@ -1,5 +1,6 @@ import express from 'express'; -import type { Server } from 'node:http'; +import { createServer, type Server } from 'node:http'; +import { listenLoopback } from '../helpers/listen.js'; /** In-process fake for the Noctusoft relay (Connect sellers + SMS + email). * Tests point RELAY_BASE_URL at this instead of a live relay, per SPEC.md: @@ -57,6 +58,11 @@ function requireApiKey(req: express.Request, res: express.Response): boolean { export async function startFakeRelay(): Promise { const app = express(); + // Never let fetch pool a socket to this fake; see test/helpers/server.ts. + app.use((_req, res, next) => { + res.set('connection', 'close'); + next(); + }); app.use(express.json()); const sms: FakeSms[] = []; @@ -135,9 +141,8 @@ export async function startFakeRelay(): Promise { res.json({ id: 'email_1' }); }); - const server: Server = app.listen(0); - const address = server.address(); - const port = typeof address === 'object' && address ? address.port : 0; + const server: Server = createServer(app); + const port = await listenLoopback(server); const base = `http://127.0.0.1:${port}`; return Object.assign(relay, { @@ -150,6 +155,10 @@ export async function startFakeRelay(): Promise { const s = sellers.get(id(product, sellerKey)); if (s) s.chargesEnabled = true; }, - close: () => new Promise((resolve) => server.close(() => resolve())), + close: () => + new Promise((resolve) => { + server.closeAllConnections(); + server.close(() => resolve()); + }), }); } diff --git a/test/health.test.ts b/test/health.test.ts index 1096538..984ad39 100644 --- a/test/health.test.ts +++ b/test/health.test.ts @@ -1,11 +1,12 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; +import { createServer } from 'node:http'; import { createApp } from '../src/server.js'; +import { listenLoopback } from './helpers/listen.js'; test('GET / returns 200', async () => { - const server = createApp().listen(0); - const address = server.address(); - const port = typeof address === 'object' && address ? address.port : 0; + const server = createServer(createApp()); + const port = await listenLoopback(server); try { const res = await fetch(`http://127.0.0.1:${port}/`); assert.equal(res.status, 200); diff --git a/test/helpers/listen.ts b/test/helpers/listen.ts new file mode 100644 index 0000000..f783350 --- /dev/null +++ b/test/helpers/listen.ts @@ -0,0 +1,21 @@ +import type { Server } from 'node:http'; + +/** Listens on an ephemeral port on 127.0.0.1, the address tests connect to, + * and resolves with the port once bound. + * + * Why not a bare listen(0): that binds every interface, and macOS will hand + * it a port number another program (an editor, a VPN, a local tool) already + * holds on 127.0.0.1. A request to 127.0.0.1: then reaches that + * program instead, and the test hangs for 300s or gets its 401. An explicit + * 127.0.0.1 bind can never take such a port. Binding with a host is + * asynchronous, so the port is read on 'listening', not right after listen(). */ +export function listenLoopback(server: Server): Promise { + return new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + server.off('error', reject); + const address = server.address(); + resolve(typeof address === 'object' && address ? address.port : 0); + }); + }); +} diff --git a/test/helpers/server.ts b/test/helpers/server.ts index 41bf729..f8742b6 100644 --- a/test/helpers/server.ts +++ b/test/helpers/server.ts @@ -1,13 +1,17 @@ -import type { Server } from 'node:http'; +import { createServer, type Server } from 'node:http'; import { createApp } from '../../src/server.js'; +import { listenLoopback } from './listen.js'; /** Starts the app on an ephemeral port for the duration of `fn`, then closes * it. Every HTTP-level test uses this instead of guessing a port. */ export async function withServer(fn: (base: string) => Promise): Promise { const app = createApp(); - const server: Server = app.listen(0); - const address = server.address(); - const port = typeof address === 'object' && address ? address.port : 0; + // Connection: close keeps fetch from pooling sockets across tests. + const server: Server = createServer((req, res) => { + res.setHeader('connection', 'close'); + app(req, res); + }); + const port = await listenLoopback(server); const base = `http://127.0.0.1:${port}`; const prev = process.env.STORE_WEBHOOK_URL; process.env.STORE_WEBHOOK_URL = `${base}/webhooks/store`; @@ -16,6 +20,7 @@ export async function withServer(fn: (base: string) => Promise): Promise((resolve) => server.close(() => resolve())); } } diff --git a/test/journeys.test.ts b/test/journeys.test.ts index 4745bd6..637fb8c 100644 --- a/test/journeys.test.ts +++ b/test/journeys.test.ts @@ -192,3 +192,71 @@ test('journey 3: full session + a waiter triggers the overflow ask, coach Y, ros }); }); }); + +test('journey 4: a new coach talks their week in and publishes it, three decisions from Start', async () => { + const { setComplete, resetComplete } = await import('../src/llm/complete.js'); + const { fakeComplete } = await import('./fakes/llm.js'); + setComplete( + fakeComplete({ + coach_timezone: null, + session_types: [{ key: 'k', name: 'Keeper Group', duration_min: 60, capacity: 8, price_cents: 3500, backup_pay_cents: null }], + weekly: [ + { type_key: 'k', weekday: 2, time_local: '18:00', location: 'Field 3' }, + { type_key: 'k', weekday: 4, time_local: '18:00', location: 'Field 3' }, + ], + packages: [], + plans: [], + }), + ); + try { + await withRelay(async (relay) => { + const db = await freshDb(); + await withServer(async (base) => { + // Decision 1: Start. + const landing = await (await fetch(`${base}/`)).text(); + assert.match(landing, /Set it up/); + assert.match(landing, /href="\/signin">Sign in m.to === '+15550004444')!.body)![1]; + const verify = await fetch(`${base}/signin/verify`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ phone: '+15550004444', code, name: 'Dana Keeper', tz: 'America/Chicago' }), + redirect: 'manual', + }); + const cookie = extractSessionCookie(verify as unknown as Response); + assert.match(await (await fetch(`${base}/app/schedule`, { headers: { cookie } })).text(), /Tell me your week/); + + // Decision 2: say the week, then "Set it up". + await fetch(`${base}/app/setup`, { + method: 'POST', + headers: { 'content-type': 'application/json', cookie }, + body: JSON.stringify({ text: 'Keeper group Tuesdays and Thursdays at 6 at Field 3, an hour, 8 kids, $35' }), + redirect: 'manual', + }); + + // Decision 3: Publish. + const draft = await db.query<{ id: number }>('select id from setup_draft'); + const published = await fetch(`${base}/app/setup/publish`, { + method: 'POST', + headers: { 'content-type': 'application/json', cookie }, + body: JSON.stringify({ id: String(draft.rows[0].id) }), + redirect: 'manual', + }); + assert.equal(published.headers.get('location'), '/app/schedule?published=1'); + + const handle = (await db.query<{ handle: string }>('select handle from coach')).rows[0].handle; + const publicPage = await (await fetch(`${base}/c/${handle}`)).text(); + assert.match(publicPage, /Keeper Group/); + assert.match(publicPage, /8 spots left/); + }); + }); + } finally { + resetComplete(); + } +}); diff --git a/test/landing.test.ts b/test/landing.test.ts new file mode 100644 index 0000000..70cfd38 --- /dev/null +++ b/test/landing.test.ts @@ -0,0 +1,313 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { freshDb } from './helpers/db.js'; +import { withServer } from './helpers/server.js'; +import { withRelay } from './helpers/relay.js'; +import { fakeComplete } from './fakes/llm.js'; +import { seedBookedSession, seedCoachWithSession } from './helpers/fixtures.js'; +import { resetComplete, setComplete } from '../src/llm/complete.js'; +import { clipStartText, decodeStartCookie, START_TEXT_MAX_BYTES } from '../src/lib/startCookie.js'; +import { APP_BASE_URL } from '../src/config.js'; + +const WEEK = { + coach_timezone: null, + session_types: [ + { key: 'group', name: 'Evening group', duration_min: 60, capacity: 8, price_cents: 3500, backup_pay_cents: null }, + ], + weekly: [{ type_key: 'group', weekday: 2, time_local: '18:00', location: 'Field 3' }], + packages: [], + plans: [], +}; + +const WEEK_TEXT = 'Tuesdays at 6, an hour, 8 athletes, $35'; + +function setCookieHeader(res: Response, name: string): string | undefined { + return (res.headers.getSetCookie?.() ?? []).find((c) => 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, /