medOS ultra

Patient QR Self-Service Pattern

Reusable recipe for QR-token public patient self-service routes: mint + verify + scope claim + tunneling + demo fallback.

5 min read diagramsUpdated 2026-05-13docs/architecture/patient-qr-self-service-pattern.md

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_SECRET so the existing passport-jwt strategy + 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 PatientMenuTokenServicemint(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 mints
  • GET /api/<feature>/:token — public, returns context + whatever the patient needs to see
  • POST /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 scope in 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, then navigator.language, then fall back to th.

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 (UnauthorizedException with the scope mismatch message).
  • No shared BaseTokenService parent 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_tokens table per feature — don’t try to centralize.
Ask Anything