EMT exam practice: NREMT-style questions by category, streaks, a leaderboard, per-user stats, and a Printful-backed swag shop. Live at weewoo.study; the test environment is test.weewoo.study.
- Plan: docs/ROADMAP.md
- Data model: docs/data-model.md
- Agent and verification rules: AGENTS.md
Deno 2 · Fresh 2 (Vite plugin) · Preact islands · Tailwind CSS 4 + DaisyUI 5 ·
Deno KV · Stripe · Printful · AWS (SES, SQS, S3, CloudWatch) · OIDC login via
auth.weewoo.study.
cp .env.example .env # or get a .env from the maintainer; see "Environment variables"
deno install
deno task dev # http://localhost:8000 with hot reloadUse STAGE=DEV locally. Never point local runs at production credentials,
payments, or email.
| Command | What it does |
|---|---|
deno task dev |
Vite dev server on port 8000 |
deno task build |
Production build into _fresh/ |
deno task start |
Serve the production build (_fresh/server.js) |
deno task test |
Unit tests (in-memory KV) |
deno task check |
deno fmt --check, deno lint, deno check on the whole repo |
deno task verify |
check + test: run this before every commit or PR |
deno task seed |
Seed .kv/seed.sqlite3 with test data (run with KV_PATH) |
deno task smoke <url> |
Read-only smoke test of a running site |
deno task e2e |
Build, seed a fresh .kv/e2e.sqlite3, run Playwright on :8123 |
deno task e2e:remote |
Playwright against BASE_URL (e.g. a branch preview) |
deno task e2e:install |
One-time: download the Chromium build Playwright uses |
e2e/tests/visual.spec.ts compares full-page screenshots of key pages at 390px
and 1280px against baselines in e2e/tests/visual.spec.ts-snapshots/. Font
rendering differs by OS, so the baselines are Linux-only: the tests run in CI's
e2e job and are skipped on Windows/macOS and against deployments. To keep them
stable, the spec blocks third-party requests, replaces remote images with a grey
placeholder, serves a fixed question from /api/question, and masks the
profile's streak countdown and chart.
When a UI change is intentional, regenerate the baselines on Linux and commit them:
- Add the
update-snapshotslabel to the PR (or, once onmain,gh workflow run update-snapshots.yml --ref <branch>). - When the "Update visual snapshots" run finishes:
gh run download <run-id> -n visual-snapshots -D e2e/tests/visual.spec.ts-snapshots - Look at the changed images, commit them, and remove the label.
When CI's e2e job fails on a snapshot, the e2e-local artifact has the
expected, actual and diff images.
To see screenshots on your own machine, opt in with VISUAL_LOCAL=1. It keeps
separate per-OS baselines (*-win32.png, *-darwin.png, gitignored) next to
the Linux ones:
VISUAL_LOCAL=1 deno task e2e visual.spec.ts # PowerShell: $env:VISUAL_LOCAL="1"; deno task e2e visual.spec.tsThe first run writes your local baselines and reports them as missing (that
counts as a failure); later runs compare against them. Open the PNGs in
e2e/tests/visual.spec.ts-snapshots/, or the HTML report in .e2e/report/ for
diffs. Delete your *-win32.png/*-darwin.png files to start over.
routes/ File-system routes (pages + API). _middleware.ts loads the
session and preferences into ctx.state for every request.
emt/ Practice (login required, see emt/_middleware.ts)
admin/, api/admin/ Admin pages and APIs (admin-only middleware)
api/ JSON APIs: question, attempt, profile, checkout, webhooks
auth/ OIDC login, callback, logout
islands/ Interactive Preact components hydrated on the client
components/ Server-rendered components
lib/ Business logic: *_store.ts wrap Deno KV (one store per entity),
plus email, logging, cron tasks, the Printful client
scripts/ One-off and admin scripts (products, Stripe, question generation)
infra/ Terraform for the AWS resources
main.ts App entry: middleware, cron registration (prod builds only)
client.ts Client entry (global CSS)
vite.config.ts Vite + Fresh + Tailwind; externalizes server-only deps
Request flow: routes/_middleware.ts → route handler → lib/*_store.ts → KV.
Pages render on the server; only islands/ ship JavaScript.
The test app (test-weewoo-study) is linked to GitHub: Deno Deploy builds every
push, deploys main to test.weewoo.study, and builds a preview of every branch.
The prod app (weewoo-study) is not linked: the Deploy production
workflow (.github/workflows/deploy-production.yml) deploys it with the Deno
Deploy CLI when the production branch moves. Deno Deploy only ever puts an
app's default git branch into production, so a per-app production branch isn't
possible through the GitHub integration.
| Deno Deploy app | Production domain | Deploys from |
|---|---|---|
test-weewoo-study |
test.weewoo.study | main |
weewoo-study |
weewoo.study | production (via GitHub Actions) |
The flow is: PR → CI (verify, local E2E, E2E against the branch preview) → merge
to main → test on test.weewoo.study → release. A release merges main
into production (--no-ff, identical tree). Agents do it with the release
skill (.claude/skills/release) when you ask. To roll back, use the previous
deployment in the Deno Deploy console.
Every branch also gets a test-app preview at
https://test-weewoo-study--<branch>.briansimoni.deno.net (<branch> with /
removed, e.g. feat/x → featx). Each branch gets its own empty KV
database, which seeds itself with test data (lib/seed.ts) on startup because
SEED_ON_EMPTY=true is set on both Deploy apps.
DENO_DEPLOY_TOKEN: Deno Deploy access token used by theDeploy productionworkflow (ideally an org-scoped token).STRIPE_TEST_API_KEY: a Stripe test-mode secret key for the shop E2E spec in the local CI job. Without it that spec is skipped. A live key makes the run fail.
This application uses various environment variables for configuration. Create a
.env file in the root directory with the following variables:
CLIENT_ID: Auth provider client ID used by the login/logout/callback routes.CLIENT_SECRET: Auth provider client secret used during OAuth callback.KV_PATH: Optional local KV file, e.g..kv/seed.sqlite3fromdeno task seed. Unset,Deno.openKv()uses Deno's default local database (or the Deploy-attached one).SEED_ON_EMPTY:trueseeds an empty database at startup (never whenSTAGE=PROD). Set on both Deploy apps (all contexts; see data-model.md).STAGE: Environment identifier.PRODenables cron jobs, real Printful orders and order emails. Non-PRODvalues skip production-only cron work, and the Stripe webhook only logs the order it would place (a dry run).LOG_LEVEL: Optional logger level. Defaults todebug.
ADMIN_API_TOKEN(Deploy apps): lets/api/admin/*acceptAuthorization: Bearer <token>besides the admin's browser session. Use a different random value per app (openssl rand -hex 32); unset or shorter than 32 characters disables token auth. Rotate it by changing the variable.WEEWOO_ADMIN_TOKEN_TEST,WEEWOO_ADMIN_TOKEN_PROD(local.envonly): the test and prod apps' tokens, used bydeno task admin <test|prod|preview-url> <METHOD> <path> [json|@file](scripts/admin_api.ts). Branch previews use the test app's token.
ADMIN_EMAIL: Destination for support emails, question report emails, SES/SQS notifications, and Stripe order alert emails.SES_FROM_EMAIL: Sender address for outgoing SES emails.RECAPTCHA_SECRET_KEY: Required by the support form API to verify reCAPTCHA.
AWS_ACCESS_KEY_ID: AWS access key used by SES, SQS, S3, and logging.AWS_SECRET_ACCESS_KEY: AWS secret access key paired with the access key ID.AWS_REGION: Optional AWS region. Defaults tous-east-1.WEEWOO_OPS_QUEUE_URL: SQS queue URL used by the cron task that polls operational notifications.
STRIPE_API_KEY: Stripe secret API key used by checkout, webhooks, and admin scripts. Its mode must matchSTAGE: the app refuses to start with a live key (sk_live_/rk_live_) outsidePROD, or a test key inPROD.STRIPE_SIGNING_SECRET: Stripe webhook signing secret used byroutes/api/stripe_webhook.ts(logic inlib/stripe_webhook.ts). Endpoints are managed withscripts/setup_stripe_webhooks.ts <test|prod>, which writes a new endpoint's secret to--secret-out <file>.PRINTFUL_SECRET: Printful API token used for product sync and order submission. Not needed by the webhook outsidePRODunlessPRINTFUL_DRAFT_ORDERSis set.PRINTFUL_DRAFT_ORDERS:truemakes the webhook outsidePRODsubmit the order to Printful (which holds it as an unconfirmed draft) instead of only logging it. No customer or admin emails are sent outsidePROD. Ignored inPROD.
CHAT_GPT_KEY: OpenAI API key used by the question-generation routes/scripts.OPENAI_API_KEY: Alternative toCHAT_GPT_KEYsupported by the workflow scripts.S3_BUCKET_NAME: Optional S3 bucket containing chapter/content assets. Defaults toems-questions-static-assets.S3_PREFIX_KEY: Optional S3 key prefix for chapter/content assets. Defaults toemt-book/.
S3_IMAGES_BUCKET: S3 bucket used byscripts/migrate_images_to_webp.ts.CLOUDFRONT_URL: CloudFront domain used byscripts/migrate_images_to_webp.tswhen generating public asset URLs.
DENO_KV_ACCESS_TOKENis not currently read anywhere in the repository.COOKIE_SECRETis not currently used by the app.STRIPE_WEBHOOK_TEMPis not currently used by the app.
Cool sounds here: https://freesound.org/search/?q=correct&f=grouping_pack%3A%2230761_feedback-correct%22
Outside STAGE=PROD both webhook handlers are dry runs: the Stripe one logs the
Printful order it would place and the Printful one logs the shipping email it
would send. Most coverage is unit tests (lib/stripe_webhook.test.ts,
lib/printful_webhook.test.ts) with fakes for Stripe, Printful and email.
Stripe
- test.weewoo.study has its own test-mode endpoint (created with
scripts/setup_stripe_webhooks.ts test). Buy something with card4242 4242 4242 4242, or replay an earlier checkout without buying again:deno run -A scripts/resend_stripe_event.ts <cs_test_… | evt_…>. Then check the Deno Deploy logs (or CloudWatch/weewoo-study/test) forReceived Stripe eventandDry run: would submit Printful order. Already processed events logSkipping duplicate Stripe event. - Locally, forward events with the Stripe CLI and use the
whsec_…it prints asSTRIPE_SIGNING_SECRET:stripe listen --forward-to localhost:8000/api/stripe_webhook, then check out in the app orstripe trigger checkout.session.completed. - Branch previews get no Stripe endpoint (one per branch isn't worth it). Rely on the unit tests, or the Stripe CLI against a local run.
Printful
Printful's v1 API has one webhook URL per store, and test.weewoo.study shares
weewoo.study's store, so only prod receives Printful webhooks
(scripts/setup_printful_webhook.ts prod; test refuses to take the URL over
without --replace). Printful webhooks are unsigned: the handler re-reads the
order from Printful and only trusts that copy. To try it locally with
STAGE=DEV, POST a package_shipped payload with a real order ID to
/api/printful_webhook (needs a valid PRINTFUL_SECRET) and look for
Dry run: would send shipping notification.
- Create the product in printful
- Download the images and upload them to the cloudfront CDN
- Update the google sheet
- Download the csv
- Upload to Stripe
- Add to the app database
Steps 5–6 are scripts/create_product.ts, which reads
scripts/products/weewoo-products.csv and weewoo-variants.csv (gitignored).
The products CSV needs a category column matching productCategories in
lib/product_store.ts.
potentially useful for extracting the urls out of s3
function getImageUris(str) {
return Array.from(document.getElementsByClassName("name object latest object-name")).map((el) => {
if (el.innerHTML.includes(str)) {
return `https://d3leqxp227sjlw.cloudfront.net/hoodie/${el.innerHTML}`
}
}).filter(Boolean).join("|")
}
products spreadsheet
https://docs.google.com/spreadsheets/d/1Tzcpc9YNc6sHVZK_6PAjQCBgEfKiQr9mZAZdaG4Nb6I/edit?gid=0#gid=0
.
Private tokens expire; rotate before then at https://developers.printful.com/tokens. Last rotated 2026-10-07 (the expiry date is shown in the Developer Portal).
- Limit the token to the weewoo.study store only. With an all-stores token,
Printful requires a
store_idthe client doesn't send: product and webhook calls fail, and orders could go to the wrong store. - Scopes: orders (view and manage), store products (view), webhooks (manage), shipping rates.
- Update
PRINTFUL_SECRETin.envand on both Deploy apps (weewoo-study,test-weewoo-study). Prod picks it up at its next deployment, so revoke the old token after that.
https://www.nremt.org/Pages/Examinations/EMR-and-EMT-Certification-Examinations