Skip to main content

Patient Appointment Respond — Confirm / Cancel via Tokenized Link

Date: 2026-04-30
Scope: 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 to portal.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:
base64url(payload) + "." + base64url(hmac)

payload = "<appointmentId>:<action>:<expiresAt>"
  action    = "confirm" | "cancel"
  expiresAt = Unix timestamp (seconds), 48-hour window

hmac = HMAC-SHA256(payload, tokenSecret)
tokenSecret = first 32 bytes of SHA-256("appt-action-v1:" + ENCRYPTION_KEY)
Derived from the existing ENCRYPTION_KEY env var — no new secret needed. Verification rules:
  1. Decode both parts from base64url.
  2. Recompute HMAC; constant-time compare.
  3. Check expiresAt > now.
  4. Look up appointment; confirm it belongs to the clinic embedded in the DB record.
  5. 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

generateAppointmentToken(appointmentId: string, action: 'confirm' | 'cancel'): string
verifyAppointmentToken(raw: string): { appointmentId: string; action: 'confirm' | 'cancel' }
// throws TokenExpiredError | TokenInvalidError
Uses Node.js 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/respondno auth middleware.
POST /api/v1/appointments/respond
Body: { token: string }
Flow:
  1. Verify token.
  2. Load appointment + patient name + clinic name from DB.
  3. If status already terminal → return { ok: true, alreadyProcessed: true, status, clinicName }.
  4. Update appointment status to confirmed or cancelled.
  5. Call queueAppointmentStatusNotifications(...) with the new status (fires email + in-app notifications to staff).
  6. Return { ok: true, status, patientName, clinicName, appointmentDate, appointmentTime }.
Rate limiting: 10 requests / minute per IP using the existing in-memory rate limiter pattern from public-referrals.ts. No Turnstile (one-click email link — adding a challenge would break the UX).

2.4 Mount in api.ts

import publicAppointmentsRoute from './routes/public-appointments';
api.route('/appointments', publicAppointmentsRoute);
// Results in: POST /api/v1/appointments/respond
Placed alongside other public routes before the protectedRoutes block.

2.5 Email changes: lib/email.ts

SendAppointmentScheduledEmailOptions gains two new optional fields:
confirmUrl?: string;
cancelUrl?: string;
When present, the rendered email template shows “Confirm Appointment” (green button) and “Cancel” (text link) below the appointment details. When absent, the email renders exactly as today (backward compatible). The same optional fields are added to sendAppointmentStatusEmails options for the scheduled status path.

2.6 Token injection: lib/appointment-notifications.ts

In the status === 'scheduled' branch, before calling sendAppointmentStatusEmails:
import { generateAppointmentToken } from './appointment-tokens';
const baseUrl = getEnv('APP_URL') || 'https://portal.odontox.io';
const confirmUrl = `${baseUrl}/appointment/respond?token=${generateAppointmentToken(appointment.id, 'confirm')}`;
const cancelUrl  = `${baseUrl}/appointment/respond?token=${generateAppointmentToken(appointment.id, 'cancel')}`;
// pass both into sendAppointmentStatusEmails
Only injected when sending to the patient (not to staff/doctor emails).

2.7 Patient portal landing page: AppointmentRespond.tsx

New component mounted at /appointment/respond on the portal.odontox.io subdomain. On mount:
  1. Read ?token= from URL query params.
  2. POST /api/v1/appointments/respond with the token.
  3. 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.”
No login required. No OdontoX nav chrome — standalone page with clinic branding from the response payload.

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

export function requireModule(moduleKey: string) {
  return async (c: Context, next: Next) => {
    const clinicContext = c.get('clinicContext');
    const clinicId = clinicContext?.currentClinicId;
    if (!clinicId) return c.json({ error: 'No clinic context' }, 403);

    const db = await getDatabase(getDatabaseUrl());
    const [row] = await db.select()
      .from(clinicModules)
      .where(and(eq(clinicModules.clinicId, clinicId), eq(clinicModules.moduleKey, moduleKey), eq(clinicModules.isEnabled, true)))
      .limit(1);

    if (!row) return c.json({ error: `Module '${moduleKey}' is not enabled for this clinic` }, 403);
    await next();
  };
}

3.2 Apply to WhatsApp config routes

In routes/whatsapp-config.ts, add requireModule('whatsapp_api') as the first middleware on every route (GET config, POST config, DELETE config, POST test, GET logs).
import { requireModule } from '../middleware/require-module';
whatsappConfigRoute.use('*', requireModule('whatsapp_api'));

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

ScenarioServer responseUI message
Token expired400 { error: 'Token expired' }”Link expired. Contact clinic.”
Token tampered400 { error: 'Invalid token' }Same
Appointment not found404”Link invalid. Contact clinic.”
Already terminal status200 { alreadyProcessed: true }”Already recorded.”
DB error500”Something went wrong. Try again.”

5. Files changed

FileTypeDescription
server/src/lib/appointment-tokens.tsNewHMAC token gen/verify
server/src/routes/public-appointments.tsNewPublic respond endpoint
server/src/middleware/require-module.tsNewModule guard middleware
server/src/api.tsEditMount public-appointments route
server/src/lib/email.tsEditAdd confirmUrl/cancelUrl to scheduled email
server/src/lib/appointment-notifications.tsEditGenerate + inject tokens for patient scheduled email
server/src/routes/whatsapp-config.tsEditApply requireModule('whatsapp_api') middleware
ui/src/components/routers/SubdomainRouter.tsxEditAdd /appointment/respond route
ui/src/components/appointments/AppointmentRespond.tsxNewPatient 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)