Patient Appointment Respond — Confirm / Cancel via Tokenized Link
Date: 2026-04-30Scope: Two sub-features: (A) patient self-serve confirm/cancel via signed email links; (B) WhatsApp add-on module gating on protected routes.
1. Context
Clinics currently send appointment emails that point toportal.odontox.io/login for any patient action. Patients cannot confirm or cancel without logging in. Staff manually chase confirmations by phone. This feature makes appointments actionable from email with a single click — no login required — and adds a clear landing page on the patient portal for the response.
The WhatsApp add-on (whatsapp_api module) already gates UI surfaces and notification sending via isWhatsAppConfiguredForClinic(). The gap is that the protected WhatsApp config and logs API routes have no module-level guard, meaning a knowledgeable user from a non-subscribed clinic could hit them directly.
2. Feature A — Patient Self-Serve Confirm/Cancel
2.1 Token design
Self-contained HMAC-SHA256 signed token, no new database table. Structure:ENCRYPTION_KEY env var — no new secret needed.
Verification rules:
- Decode both parts from base64url.
- Recompute HMAC; constant-time compare.
- Check
expiresAt > now. - Look up appointment; confirm it belongs to the clinic embedded in the DB record.
- If appointment status is already terminal (
confirmed,cancelled,completed,no_show) — return a graceful “already processed” response, do not re-fire notifications.
2.2 New server library: lib/appointment-tokens.ts
crypto.createHmac (available in Cloudflare Workers via the Web Crypto polyfill already in the project).
2.3 New public route: routes/public-appointments.ts
Mounted at /api/v1/appointments/respond — no auth middleware.
- Verify token.
- Load appointment + patient name + clinic name from DB.
- If status already terminal → return
{ ok: true, alreadyProcessed: true, status, clinicName }. - Update appointment status to
confirmedorcancelled. - Call
queueAppointmentStatusNotifications(...)with the new status (fires email + in-app notifications to staff). - Return
{ ok: true, status, patientName, clinicName, appointmentDate, appointmentTime }.
public-referrals.ts.
No Turnstile (one-click email link — adding a challenge would break the UX).
2.4 Mount in api.ts
protectedRoutes block.
2.5 Email changes: lib/email.ts
SendAppointmentScheduledEmailOptions gains two new optional fields:
sendAppointmentStatusEmails options for the scheduled status path.
2.6 Token injection: lib/appointment-notifications.ts
In the status === 'scheduled' branch, before calling sendAppointmentStatusEmails:
2.7 Patient portal landing page: AppointmentRespond.tsx
New component mounted at /appointment/respond on the portal.odontox.io subdomain.
On mount:
- Read
?token=from URL query params. POST /api/v1/appointments/respondwith the token.- Render state:
- Loading: spinner + “Processing your response…”
- Confirmed: green checkmark, “Appointment Confirmed”, patient name, date/time, clinic name, “We look forward to seeing you!”
- Cancelled: neutral icon, “Appointment Cancelled”, “If you’d like to reschedule, please contact [clinicName].”
- Already processed: “Your response was already recorded.” + current status label.
- Error (expired/invalid): “This link has expired or is invalid. Please contact the clinic directly.”
2.8 SubdomainRouter wiring
ui/src/components/routers/SubdomainRouter.tsx — add a route for portal.odontox.io/appointment/respond that renders AppointmentRespond without the authenticated layout.
3. Feature B — WhatsApp Add-on Gating
3.1 New middleware: middleware/require-module.ts
3.2 Apply to WhatsApp config routes
Inroutes/whatsapp-config.ts, add requireModule('whatsapp_api') as the first middleware on every route (GET config, POST config, DELETE config, POST test, GET logs).
3.3 All notification sends — already correct
isWhatsAppConfiguredForClinic() already checks isEnabled = true + valid credentials. No changes needed to the notification flow, reminders cron, or the new public respond endpoint.
3.4 UI — already correct
hasModule('whatsapp_api') gates all WhatsApp UI surfaces. No changes needed.
4. Error handling
| Scenario | Server response | UI message |
|---|---|---|
| Token expired | 400 { error: 'Token expired' } | ”Link expired. Contact clinic.” |
| Token tampered | 400 { error: 'Invalid token' } | Same |
| Appointment not found | 404 | ”Link invalid. Contact clinic.” |
| Already terminal status | 200 { alreadyProcessed: true } | ”Already recorded.” |
| DB error | 500 | ”Something went wrong. Try again.” |
5. Files changed
| File | Type | Description |
|---|---|---|
server/src/lib/appointment-tokens.ts | New | HMAC token gen/verify |
server/src/routes/public-appointments.ts | New | Public respond endpoint |
server/src/middleware/require-module.ts | New | Module guard middleware |
server/src/api.ts | Edit | Mount public-appointments route |
server/src/lib/email.ts | Edit | Add confirmUrl/cancelUrl to scheduled email |
server/src/lib/appointment-notifications.ts | Edit | Generate + inject tokens for patient scheduled email |
server/src/routes/whatsapp-config.ts | Edit | Apply requireModule('whatsapp_api') middleware |
ui/src/components/routers/SubdomainRouter.tsx | Edit | Add /appointment/respond route |
ui/src/components/appointments/AppointmentRespond.tsx | New | Patient landing page |
6. Out of scope
- WhatsApp template button deep-linking (requires re-submitting Meta templates; separate task)
- Patient authentication / portal account required for confirm/cancel
- SMS delivery of confirm links
- Admin UI to see who confirmed via link vs. phone (status change is already recorded in the activity log)

