Patient QR Self-Service Pattern
Reusable recipe for QR-token public patient self-service routes: mint + verify + scope claim + tunneling + demo fallback.
A reusable recipe for “scan a bedside QR, do something as the patient, no login” features. The nutrition self-order flow at /patient-menu/:token (shipped in claude/nutrition-menu-visualization-PZYVS) is the reference implementation. Use this doc to extend the same pattern to pharmacy refills, lab result release, blood-donor scheduling, etc.
Architecture
┌──────────────┐ POST /api/<feature>/token ┌──────────────────┐
│ Staff worklist│ ───────────────────────────────────▶ │ public-api │
│ "Generate QR" │ { encounterId, scope, exp } │ <Feature>Module │
│ (JWT auth) │ ◀─── { token } (signed JWT) ─────── │ - TokenService │
└──────────────┘ │ - Controller │
│ - Service │
(encode in QR) └──────┬───────────┘
│
┌──────────────┐ scan, browser opens /<feature>/<token> │
│ Patient │ ───────────────────────────────────────────────▶
│ (mobile) │ GET /api/<feature>/:token (public, token verified)
│ │ POST /api/<feature>/:token/... (public, token verified)
└──────────────┘
Why it works
- Token IS auth. The patient never logs in. The signed JWT scopes them to one encounter, one feature, one short window.
- One shared secret. Reuse
JWT_SECRETso the existingpassport-jwtstrategy + nginx ALB rules already work — no new keys, no new infra. - Scope claim isolates features. A patient-menu token cannot accidentally unlock a pharmacy-refill endpoint, even if leaked. The verify step rejects mismatched scopes.
- Public route, no global guard. Each feature controller verifies the path token inline; the rest of the app keeps its auth wall.
- Demo fallback. Frontend detects short /
demo-*tokens and renders canned data — Vercel previews stay clickable without needing a live backend.
Files in the reference implementation
| Path | Role |
|---|---|
services/public-api/src/api/publicapi/modules/patientMenu/patientMenu.token.ts |
PatientMenuTokenService — mint(input) and verify(token). Claims { sub, pid, hn?, adm?, dis?, scope: 'patient-menu', iat, exp }. Default TTL 7d, cap 30d. |
services/public-api/.../patientMenu.controller.ts |
POST /api/patient-menu/token (auth), GET /api/patient-menu/:token (public), POST /api/patient-menu/:token/orders (public). |
services/public-api/.../patientMenu.service.ts |
Orchestrates Moleculer calls — encounter/patient lookup, allergen list, menu catalog, per-slot nutritionRequest.create (partial-success tolerant). |
services/public-api/.../patientMenu.module.ts |
Registers JwtModule with config.JWT_SECRET + Passport. Add to app.module.ts. |
web/src/services/patient-menu.service.ts |
mintPatientMenuToken, loadPatientMenu, submitPatientMenuOrder. All hit /api/patient-menu/* so Vercel rewrites tunnel to the ALB. |
web/src/containers/patient-menu/page.tsx |
Public route. Loads on mount, demo-fallback on failure, submits the multi-day plan. |
web/packages/healthops-kit/src/nutrition/components/patient-menu/PatientMenuPage.tsx |
Mobile-first UI. Receives token, context, options, onSubmit, locale. |
web/packages/.../work-list/NutritionWorkListPage.tsx |
Staff worklist with “Generate QR” dialog that mints a real token + renders the QR. |
Recipe for a new feature (“Pharmacy refill QR”, “Lab result release”, …)
1. Pick a scope string
patient-menu, pharmacy-refill, lab-release, donor-schedule, etc. Never reuse another feature’s scope.
2. Mint/verify service (copy patientMenu.token.ts)
@Injectable()
export class PharmacyRefillTokenService {
constructor(private readonly jwt: JwtService) {}
mint(input: { encounterId: string; patientId: string; prescriptionId?: string; expiresInSec?: number }) {
const ttl = Math.min(input.expiresInSec ?? 60 * 60 * 24 * 7, 60 * 60 * 24 * 30);
return this.jwt.sign({
sub: input.encounterId,
pid: input.patientId,
rx: input.prescriptionId,
scope: 'pharmacy-refill',
}, { expiresIn: ttl });
}
verify(token: string) {
const claims = this.jwt.verify<any>(token);
if (claims.scope !== 'pharmacy-refill') throw new UnauthorizedException('scope mismatch');
return claims;
}
}
3. Controller with three endpoints
POST /api/<feature>/token—@UseGuards(AuthGuard('jwt')), staff mintsGET /api/<feature>/:token— public, returns context + whatever the patient needs to seePOST /api/<feature>/:token/<action>— public, performs the action
4. Module registers JwtModule with config.JWT_SECRET
Copy patientMenu.module.ts verbatim — only the names change.
5. Wire into app.module.ts
6. Frontend service helpers in web/src/services/<feature>.service.ts
Three thin fetch calls. All paths must start with /api/ so Vercel’s existing rewrite tunnels them to the ALB — no env juggling.
7. Public route in web/src/routes.tsx
Above the ProtectedLayout, like /patient-menu/:token. Use }>.
8. Staff “Generate QR” dialog
Encounter-ID input → mint button → /' + token} />. The worklist NutritionWorkListPage.tsx is the reference; lift the dialog into a shared component if you ship a third one.
Conventions to keep
- Default TTL: 7 days. Cap: 30 days. Adjust per feature only if there’s a real reason.
- Always include
scopein claims. Verify rejects mismatched scopes — defence-in-depth against token reuse. - Partial-success tolerant submit. If you’re creating N records, don’t fail the whole submission because slot 17 of 21 hit a transient error. Log + continue + return what succeeded.
- Demo-token fallback on the frontend. Short / unknown tokens skip the network and render canned data. Lets you demo before the backend ships and prevents Vercel-preview rot.
- Locale prop, default Thai. Match the patient-menu defaults. Detect
?lang=first, thennavigator.language, then fall back toth.
Candidates already proposed for this pattern
| Feature | Token claims | Public actions |
|---|---|---|
| Pharmacy refill request | { patientId, prescriptionId } |
View Rx, choose pickup slot, submit refill request |
| Lab result release | { patientId, encounterId } |
View ready-to-release results, acknowledge, request paper copy |
| Blood-donor scheduling | { donorId } |
View upcoming drives, book a slot, fill pre-screening |
| Bedside survey / PROM | { encounterId, surveyId } |
Fill questionnaire, submit |
| Admission paperwork | { encounterId } |
Sign consent, upload ID photo, fill insurance |
| Visitor pre-registration | { patientId } |
Add visitor names, get visit QR code |
Each shares the same skeleton; only the Moleculer calls inside the service differ.
What’s intentionally NOT shared
- No global guard. Each controller verifies inline so the failure mode is clear (
UnauthorizedExceptionwith the scope mismatch message). - No shared
BaseTokenServiceparent class. The few lines of duplication (mint + verify + scope check) are clearer per-feature than a 3-level abstraction. Resist the urge to DRY. - No DB-side token table. JWT signatures + exp claim do all the work. If you need revocation, add a tiny
revoked_tokenstable per feature — don’t try to centralize.