# Rosters — AHA class roster + public student self-signup

Node backend (port **3017**, localhost-only) + static PWA frontend (`apps/rosters/`). Lets Adam create
AHA courses (ACLS / BLS / PALS / …) taught at Ace Medical, share a per-class public
signup link, review self-registered students, and copy each approved student's data into Enrollware and
the AHA eCards site via tab-separated "paste-chain" clipboard helpers.

## Surfaces

1. **Admin panel** — `/rosters/` (`apps/rosters/index.html`). Create classes (pick one or more course
   types — a class isn't limited to a single AHA course), manage rosters, copy signup links, review
   **Pending** vs **Approved** students, edit fields (incl. which course(s) each student is taking, when the
   class offers more than one), approve/reject, manually add walk-ins, and use the per-student **"Fill it
   out"** copy helpers — which render one AHA eCards assignment block per course the student actually took. **Has its own bcrypt password**
   (`ROSTERS_PASSWORD_HASH`) — the same model as npa, **NOT** the main adampowell.pro login. This is
   deliberate: the panel is publicly reachable so it works from a device that isn't signed into the main
   site; its own password is the gate. The **login screen is intentionally wordless** — just a blank
   password box (submit on Enter; a wrong password gives a red shake, no text), and the header/footer only
   render after unlock.

2. **Public student signup** — `/rosters/signup/<slug>` (`apps/rosters/signup.html`). No auth; scoped to
   one unguessable per-class slug. Collects first name, last name, email, **phone, and home/business
   address — all required** (NOT scores) and creates a **pending** student. A class can offer **more than
   one course type at once** (e.g. a combined ACLS+BLS+PALS class) — when it does, the student sees a
   checkbox picker and chooses which course(s) they're actually taking (`courses[]` on the student record);
   single-course classes show no picker at all, nothing changes for the common case. (Manual admin add
   treats email/phone/address as optional — for walk-ins where you may not have them all.)

3. **Public class-request form** — `/requests` (`apps/rosters/requests.html`). Fully public, no auth. A
   visitor enters name/email/phone/address, multi-selects class(es), **picks one of two course formats**,
   proposes free-form preferred date(s)/time(s), and adds optional notes. On submit it **emails Adam**
   (`ROSTERS_NOTIFY_EMAIL`, default `apowell-llc@pm.me`) and lands in the **Requests** section of the admin
   panel. Protected by an entry gate + custom anti-bot challenge (see below) + honeypots + min-fill-time +
   per-IP rate limiting + USA-only. Nothing is booked — it's an inquiry; Adam confirms.
   **Two AHA course paths (the `opt` field):**
   - **In person with instructor** (`inperson`, default) — the whole course in one in-person session: the
     official course videos (played by the instructor), the AHA Provider Manual (each student needs their
     **own** current manual), the **written exam proctored in person**, and the hands-on skills. No separate
     online purchase. For this path a **"Do you have your Provider Manual?"** question shows (`manual`:
     `have`/`need`/`unsure`).
   - **Online first (HeartCode)** (`online`) — the student buys the official **HeartCode** online course
     (precourse + videos + exam, on AHA's platform; links to HeartCode ACLS/BLS/PALS shown), then books a
     skills session. The manual question is hidden (server clears `manual` for this path).
   **No AHA content (videos/exam) is hosted on adampowell.pro** — a deliberate compliance decision (AHA
   copyright + exam-security; the online exam is inside each student's HeartCode purchase, and the in-person
   exam is the paper exam the instructor proctors + collects — never posted). `opt` (+ `manual` for
   in-person) show in the admin card + notification email.

4. **Per-request Venmo pay page** — `/rosters/pay/<token>` (`apps/rosters/pay.html`). After confirming a
   class, Adam sets an **amount** (+ optional **coupon** = code + % off) on the request in the admin panel →
   that mints a `payToken` + pay link he sends the student. The page shows the amount, the Venmo QR (@ATP28,
   `venmo-qr.jpg`), and a **Pay with Venmo** button deep-linked with the amount + note prefilled. A coupon
   code box (shown only if a coupon is attached) applies the % off and updates the total + Venmo amount — the
   code is validated server-side and **never sent to the client** (wrong code → `valid:false`). The student
   clicks **"I've sent it"** + gives their Venmo handle → the request is flagged **payment claimed** (emails
   Adam); Adam checks his Venmo app and clicks **Mark paid** in the admin panel. **A personal Venmo has no
   API to auto-confirm payments** — this is deliberately a manual-verify loop (the app tracks the claim +
   Adam's verification). Venmo user = `ROSTERS_VENMO_USER` (env, default `ATP28`).

## Anti-bot challenge (custom, free, no third-party, accessible)

The `/requests` feature is defended in depth, all in-app (no reCAPTCHA / external service / tracking):

- **Entry gate (blocks scrapers from the content itself).** The page loads showing ONLY an anesthesia
  question — no form fields, no class list. Solving it (`POST /api/request/enter`) mints a short-lived
  (20 min), IP-bound, HMAC-signed **entry pass**. The server then **withholds the course list**
  (`GET /api/request/courses` returns 401 without a pass) **and rejects submissions** without one — so a
  scraper that never answers the question gets an empty shell and no data. The entry-captcha endpoint is
  itself rate-limited (12/min/IP) and geo-gated.
- **USA-only.** All request endpoints require the visitor to be US-based, read from Cloudflare's
  `CF-IPCountry` header (the droplet has no GeoIP DB; CF adds this on every proxied request). Non-US →
  403 with a polite "US only" message. A **missing** country header is allowed through (only the raw
  origin lacks it, and nginx is the sole public path) so a CF hiccup can't falsely block a real US user.
- **Anesthesia board-style question** — the server picks one from a **pool of ~60 board questions**
  (`ANES_QA` in `server.js`) — e.g. "Reversal of rocuronium is best achieved with which agent?" (sugammadex),
  "First-line treatment for anaphylaxis under anesthesia?" (epinephrine), "MAC stands for minimum alveolar
  ___?". Each question has a **generous accepted-answer set** (synonyms, brand↔generic, common misspellings —
  e.g. sugammadex/Bridion; naloxone/Narcan; dantrolene/Dantrium), and answers are **normalized** (case,
  punctuation, filler words) so a real clinician isn't rejected for a valid phrasing. The **answers never
  reach the browser**: the server returns only the question + an opaque **HMAC-signed token**
  (`ROSTERS_CAPTCHA_SECRET`) encoding a hash of **each accepted answer form** + a nonce + timestamp. A bot
  can't read the answer off the page or forge/replay a token (**single-use** nonce, short-lived 10 min).
  Fully text/keyboard/screen-reader answerable — no images, no drag (accessibility). Requesters who aren't
  sure can cycle to a different question. To add/edit questions, edit the `ANES_QA` array and redeploy.
  **Trade-off vs the old random-arithmetic puzzle:** a *fixed* question pool is theoretically harvestable by
  a determined attacker who solves it repeatedly — mitigated by the large pool + generous matching. It stays
  strong against generic spam bots (they can't answer domain questions).
- **Honeypots** — hidden `website`/`company` fields a human never sees; if filled, the server returns a
  fake `{ok:true}` and **silently drops** the submission (no record, no email — the bot gets no signal).
- **Min-fill-time** — the form stamps `loadedAt`; a submit faster than a human could type is silently
  dropped the same way.
- **Rate limit** — 3 requests / minute / IP.

This is genuinely strong against automated spam (the real threat) — it is **not** a claim of being
unbeatable by a determined human who studies it once. Rotate the secret via `ROSTERS_CAPTCHA_SECRET`.

## Auth model (important — admin DOUBLE-gated as of 2026-07-01)

The **admin** panel + admin API are gated by the **main adampowell.pro login (`auth_request`) ON TOP of**
the app's own bcrypt password. The **public** student/request/pay routes stay ungated (scoped by slug /
captcha entry-pass / pay token). _(Was own-password-only until 2026-07-01, when the main login was added for
abuse prevention — trade-off: the admin panel is no longer reachable from a device not signed into the main
site.)_

| Surface | Path | Gate |
|---|---|---|
| Admin panel | `/rosters/` | **main login (`auth_request`)** + own bcrypt password → 12h **sliding** bearer token (localStorage) |
| Admin API | `/rosters/api/*` **except** the public prefixes below | **main login** + bearer token (`requireAdam`) |
| Public signup page + API | `/rosters/signup/<slug>`, `/rosters/api/signup/<slug>` | **none** — slug is the scope |
| Public request page + API | `/requests`, `= /rosters/api/request` + `/rosters/api/request/` | **none** — captcha entry-pass |
| Public pay page + API | `/rosters/pay/<token>`, `/rosters/api/pay*` | **none** — per-request pay token |

nginx carve-out (both APIs live under `/rosters/api/`): the public request API is split into an **exact
`= /rosters/api/request` + a trailing-slash `/rosters/api/request/`** so the admin `/rosters/api/requests*`
routes are NOT un-gated by a bare `/request` prefix (a real hole caught by adversarial verification);
`/rosters/api/pay` + `/rosters/api/signup/` are the other ungated API prefixes; then `/rosters/api/` +
`/rosters/` get `auth_request`. A server-level `limit_req_zone` (60r/m per CF-Connecting-IP) backstops the
public API blocks. Search exclusion: ungated `/robots.txt` (`Disallow: /`) + `X-Robots-Tag: noindex` header.

Admin auth (app layer, under the main gate): Adam POSTs the password to `/api/auth`, gets a
`crypto.randomBytes(32)` session token (2h, in-memory), sent as `Authorization: Bearer` on every admin call.
`rosters.service` **refuses to start** if `ROSTERS_PASSWORD_HASH` is unset. A bad signup slug
returns a **generic 404** (same as any other), so valid and invalid slugs are indistinguishable.

Rotate the admin password: regenerate the hash
(`node -e "console.log(require('bcrypt').hashSync(process.argv[1],10))" '<new pw>'`), replace
`ROSTERS_PASSWORD_HASH` in `/root/secrets.env`, `systemctl restart rosters.service`.

## Storage

Single atomic-write JSON store `data/rosters.json` (`.tmp` + rename) with rolling 50 backups in
`data/backups/` — same pattern as case-tracker / npa (SYSTEM.md §14). Holds student PII (names, emails,
phones) so `data/` is **gitignored** (an explicit rule — not relying on the global `*.json`). The editable
`config` block (course list, AHA course-name map, paste-chain field orders, Training Center/Site, defaults)
lives inside the store; its *defaults* are seeded from `DEFAULT_CONFIG` in `server.js` on first boot, after
which the admin panel's config editor (`PUT /api/config`) owns them — correct names / reorder fields with
no code change or redeploy.

## The paste-chain copy helpers (and their hard ceiling)

For each approved student the admin panel offers two **paste-chain** copy buttons, each putting a single
**tab-separated** string on the clipboard in the order the target form expects, plus individual
copy-icons per value as a fallback:

- **Enrollware "Student Scores"** — Precourse Self-Assessment, Test Score, CCF% (order configurable).
  Every configured key is **always** in the chain — a blank CCF% is included as an empty value so the
  tab count stays consistent. Click the form's first field, paste, and tab-stops carry the rest across.
- **AHA eCards "Assign to Students"** — default order is **Date** (the class's scheduled date, `MM/DD/YYYY`),
  Student first/last, Student email (order configurable). One AHA eCards block renders **per course a student
  took**, so the whole chain is student-row data only — **Training Center / Training Site / Course /
  Instructor are ONE-TIME dropdowns at the top of heart.org's form, set once per batch, NOT per-student
  fields**, so they're intentionally excluded from the default chain (2026-07-27, confirmed against the live
  form). They're still valid config keys — re-add them via Settings if heart.org's form ever changes.
  `address` is also a valid AHA key not in the default order — add it in Settings once you know where
  heart.org wants it.

**Constraint:** the same-origin policy means a button on adampowell.pro **cannot** write into a form field
on enrollware.com or heart.org. There is no userscript / extension / headless-automation path that wouldn't
mean driving someone else's logged-in session — explicitly out of scope. The tab-separated clipboard +
manual paste-and-tab is the realistic ceiling. If paste-and-tab doesn't land cleanly on one of those sites
in practice, the fix is to **reorder the field config** (or use the per-value copy icons), not to script the
other site. Flag it rather than build a workaround.

## Endpoints

Public — signup (ungated prefix `/api/signup/`):
- `GET  /api/signup/:slug` — class confirmation fields only (courseTypes[], classDate, `closed` — **no
  location/business name is exposed publicly**); generic 404 for a bad slug.
- `POST /api/signup/:slug` — create a pending student (first, last, email, phone, address — **all required**;
  `courses[]` required too when the class offers more than one course type, otherwise auto-assigned);
  per-IP rate limited; scores are always forced empty; **409s with `{error:'closed'}` if the class has
  registration closed** (server-side enforcement backing the admin toggle, not just a frontend hide).

Public — class requests (US-only, entry-gated). All require `CF-IPCountry: US` (or absent):
- `GET  /api/request/entry` — issue the ENTRY anesthesia challenge `{question, token}` (rate-limited 5/min/IP + 8-distinct-questions/hour/network).
- `POST /api/request/enter` — verify the entry answer (+ min solve time) → returns a short-lived `entryPass`.
- `GET  /api/request/form` — **the form markup itself** (`requires X-Entry-Pass`). The `/requests` page ships ONLY the captcha shell (no form fields/pricing/class-list/HeartCode links in source); the form is fetched + injected client-side only after the captcha is solved, so a scraper hitting `/requests` gets an empty shell. The captcha question text is also `user-select:none`. Pricing strings live in `data-*` attrs on this gated form, not the shell.
- `GET  /api/request/courses` — course-type list; **requires a valid `X-Entry-Pass`** (401 without) so scrapers can't harvest it.
- `POST /api/request` — submit; **requires `entryPass`** + honeypot + min-fill-time + rate-limit; emails Adam.

Admin auth:
- `POST /api/auth` — exchange the password for a bearer token (rate-limited)
- `GET /api/session` — probe whether the current bearer token is still valid

Admin (require the bearer token via `requireAdam`):
- `GET|PUT /api/config`
- `GET|POST /api/classes`, `GET|PATCH|DELETE /api/classes/:slug` — `PATCH` also accepts `{closed: bool}` to
  toggle "Close registration" (blocks new public signups ONLY — roster/pay-link/paste-chain/evals/exams all
  keep working; fully reversible)
- `POST /api/classes/:slug/students` (manual add → approved by default)
- `PATCH|DELETE /api/classes/:slug/students/:id`, `POST /api/classes/:slug/students/:id/approve`
- `GET /api/requests`, `PATCH /api/requests/:id` (mark handled/new), `DELETE /api/requests/:id`
- `POST /api/requests` — admin creates a request record directly (name/email/phone/address/courses/opt/manual),
  no captcha/entry-pass/rate-limit (already authenticated), created `status:'handled'`, no notify email. For a
  student Adam already has contact info for (e.g. approved off a class signup) who he wants to send a Venmo pay
  link + the post-payment eval/answer-sheet flow to, without routing them through the public `/requests` form.
  Admin panel: "+ Add a student to send a pay link" in the Class requests section.
- `POST /api/requests/:id/pay-link` (set amount + optional coupon → mint pay token), `POST /api/requests/:id/mark-paid`
- `POST /api/requests/:id/send-pay-link` — email the student their Venmo pay link (+ price, and the coupon
  code/discounted price if one is set) using whatever amount/coupon is currently saved on the request. 409 if
  no amount set / no pay token / no email on file. Admin panel: "Send pay link to `<email>`" button next to
  "Save & copy link" in each request's payment row — first re-saves whatever's in the amount/coupon fields
  (so it always sends the price on screen, not a stale one), then sends. Hidden entirely (replaced with a
  "no email on file" hint) when the request has no email — e.g. an admin-created request left it blank.
- `POST /api/requests/:id/resend-followup` — re-send the paid student their eval + answer-sheet links by email (409 if not paid / no pay token / no email)

**Both student-facing emails (send-pay-link + the paid-confirmation email) use a shared branded HTML
template** (`emailShell()` / `emailPriceBox()` / `emailButton()` in server.js) — table-based, fully
inline-styled (not a `<style>` block, which many mail clients strip), navy header card matching the site's
accent color, a styled price callout, real button-styled CTAs. No "Ace Medical"/"Allied 100" branding
(matches the rest of the public surfaces) — identity is just "Adam Powell" / "adampowell.pro" in the footer,
same as elsewhere. Verified by rendering the generated HTML in a headless browser (catches broken
tables/tags) before a live send.
- `POST /api/requests/:id/reset-evals` / `POST /api/requests/:id/reset-answersheets` — delete ALL of this
  student's saved eval/answer-sheet records and clear `evalsDone`/`sheetsDone`, so their pay-token eval/exam
  pages reopen fresh as if never submitted. Admin panel: "Reset evaluation(s)" / "Reset exam answer sheet(s)"
  buttons in each Class History card (only shown when there's something to reset). `DELETE /api/evals/:id` and
  `DELETE /api/answersheets/:id` were ALSO fixed to clear the matching done-flag on the owning request — before
  this fix, deleting a single record left the student permanently unable to resubmit (the done-flag never
  cleared, and the idempotent POST route treats "already in evalsDone/sheetsDone" as already-submitted).

**Marking a request paid** now, on the *first* unpaid→paid transition, auto-emails the student their two
next-step links (course evaluation `/rosters/eval/<payToken>` + exam answer sheet `/rosters/answersheet/<payToken>`),
fire-and-forget (never blocks the response; records `followupEmailedAt` / `followupEmailError` on the request).
`resend-followup` re-sends the same email on demand. The admin panel's payment row shows, per paid student,
copy-link buttons for both, whether each is completed (from `evalsDone` / `sheetsDone` vs the purchased courses),
and a resend button. Links use `ROSTERS_BASE_PUBLIC` (default `https://adampowell.pro`).

**The same email also attaches the AHA 2025 algorithm/guideline PDFs**, scoped to the course(s) the student
actually bought (`guidelineAttachmentsFor()` in server.js) — `AHA_2025_Changes_Summary.pdf` always first
(applies to every course), then the course-specific ones (ACLS → ACLS Guidelines + Special Circumstances +
Post Cardiac Arrest Care; BLS → Adult BLS + Pediatric BLS; PALS → PALS + Special Circumstances + Post
Cardiac Arrest Care). Buying multiple courses dedupes the shared files (no duplicate attachments). Source
PDFs live in `apps/server/rosters/attachments/` (committed to git — the only way a file reaches the server
is via the deploy pipeline; there's no server-only asset path for this app). To update a guideline when AHA
revises it, replace the file at the same filename and push — no code change needed unless the filename or
course mapping changes.

Public — Venmo pay (token-gated, per request):
- `GET  /api/pay/:token` — amount, course(s), prefilled Venmo URL, `hasCoupon` (never the code)
- `POST /api/pay/:token/coupon` — validate a submitted code → discounted amount + updated Venmo URL (rate-limited)
- `POST /api/pay/:token` — student marks "I paid" (+ their Venmo handle) → flags claimed, emails Adam

Public — course evaluation + exam answer sheet (token-gated, **only after PAID**):
- `GET  /api/pay/:token/evals` — the eval(s) needed for the purchased class(es) (title/objectives/actionLabel + `done`)
- `POST /api/pay/:token/evals` — save ONE course's completed eval (`{ course, answers }`); pushes to `evalsDone`
- `GET  /api/pay/:token/answersheet` — the exam(s) needed + fixed **`numQuestions`** per course (`EXAM_QUESTION_COUNTS`: BLS 25, ACLS 50, PALS 50) + `done`
- `POST /api/pay/:token/answersheet` — save ONE course's bubbled answers (`{ course, answers, numQuestions, version }`); pushes to `sheetsDone`

The moment BOTH the eval(s) AND answer sheet(s) for a request are fully submitted (whichever finishes second
triggers it — order-independent), `notifyIfFullyDone()` emails Adam (`ROSTERS_NOTIFY_EMAIL`) that the student
is done and ready to grade. Fires exactly once per request via a `completionNotifiedAt` guard; the admin reset
routes (bulk + single-record delete, below) clear that guard so a genuine reset-and-resubmit re-fires it.

Both public pages render **all the student's courses on ONE combined page** (`eval.html` / `exam.html` — no per-course
picker) with a **single submit button**. The client **hard-blocks** submission until every field of every course is
answered (no "submit anyway"), then POSTs each course to the per-course endpoint above in turn. The answer sheet is a
fixed-length A–D bubble grid per course (length from the server's `numQuestions`, no student-editable count); **no AHA
exam content is ever stored or served** — only the student's bubbled answers + a score Adam records later.

Public (no auth):
- `GET /api/health` (leaks nothing — just `{ok, classes, requests}`)

## Non-goals (v1)

No login automation / scraping / API calls against enrollware.com or heart.org. No student emails. No
multi-user / multi-instructor. No PDF / certificate handling. (The admin panel *does* have its own
password — an intentional switch from the initial main-login-gated build so it's usable off the main site.)

## Run

```
npm install                                  # express + bcrypt + nodemailer
# ROSTERS_PASSWORD_HASH (required) + ROSTERS_CAPTCHA_SECRET (recommended; else derived from the hash)
ROSTERS_PASSWORD_HASH=<bcrypt-hash> node server.js   # listens on 127.0.0.1:3017 (refuses to start without the hash)
```

Deployed as systemd `rosters.service` (`EnvironmentFile=/root/secrets.env`, `Restart=on-failure` +
`StartLimitBurst=3`, bound 127.0.0.1). See `docs/WHEN CREATING A NEW APP.md` and `docs/SYSTEM.md`.
